From 4e767870bdccedd44e8359ffd721ee6d2365b2e8 Mon Sep 17 00:00:00 2001 From: t-roi <78104335+t-roi33@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:35:29 +0200 Subject: [PATCH 1/3] Fix clear_out_obsolete_test_names never persisting its cleanup The before/after counters iterated the keys of tests_by_mangled_function_name, so they measured the length of each mangled function name instead of the size of its test set. The dict comprehension between them keeps every key, so both sums were always equal, the "Removed N obsolete test names" message never printed and save_stats() never ran from here. Obsolete node IDs therefore stayed in mutants/mutmut-stats.json indefinitely, even though the in-memory filtering worked. Count the test sets instead, and add a unit test that checks the cleanup reaches the stats file. Fixes #564 --- HISTORY.rst | 2 ++ src/mutmut/runners/harness.py | 4 +-- tests/runners/__init__.py | 0 tests/runners/test_harness.py | 56 +++++++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 tests/runners/__init__.py create mode 100644 tests/runners/test_harness.py diff --git a/HISTORY.rst b/HISTORY.rst index 95a74b65..7b2b9b7d 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -4,6 +4,8 @@ Changelog Unreleased ~~~~~~~~~~ +* Fix obsolete test names never being removed from ``mutants/mutmut-stats.json``: the counters guarding the save iterated dict keys instead of the test sets, so the cleanup was applied in memory but never persisted (`#564`) + * Fix ``# pragma: no mutate block`` being silently ignored when placed on an ``else``, ``except``, ``except*`` or ``finally`` header, and on the ``try`` line of a ``try``/``except*`` * Fix mutants being reported as survived when a test uses ``patch.dict(os.environ, ..., clear=True)`` (`#511`) diff --git a/src/mutmut/runners/harness.py b/src/mutmut/runners/harness.py index 6e550afa..f68863f4 100644 --- a/src/mutmut/runners/harness.py +++ b/src/mutmut/runners/harness.py @@ -60,7 +60,7 @@ def __init__(self, *, ids: set[str]) -> None: self.ids = ids def clear_out_obsolete_test_names(self) -> None: - count_before = sum(len(x) for x in state().tests_by_mangled_function_name) + count_before = sum(len(x) for x in state().tests_by_mangled_function_name.values()) state().tests_by_mangled_function_name = defaultdict( set, **{ @@ -68,7 +68,7 @@ def clear_out_obsolete_test_names(self) -> None: for k, test_names in state().tests_by_mangled_function_name.items() }, ) - count_after = sum(len(x) for x in state().tests_by_mangled_function_name) + count_after = sum(len(x) for x in state().tests_by_mangled_function_name.values()) if count_before != count_after: print(f"Removed {count_before - count_after} obsolete test names") save_stats() diff --git a/tests/runners/__init__.py b/tests/runners/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/runners/test_harness.py b/tests/runners/test_harness.py new file mode 100644 index 00000000..d6c36f0e --- /dev/null +++ b/tests/runners/test_harness.py @@ -0,0 +1,56 @@ +"""Tests for the incremental-stats bookkeeping in ``ListAllTestsResult``.""" + +import json +from collections import defaultdict +from pathlib import Path + +import pytest + +from mutmut.runners.harness import ListAllTestsResult +from mutmut.state import state + +STATS_FILE = Path("mutants/mutmut-stats.json") + + +@pytest.fixture(autouse=True) +def isolated_stats(tmp_path, monkeypatch): + # save_stats() writes mutants/mutmut-stats.json relative to the cwd, and it + # loads the config on the way, which needs a guessable source dir to exist. + monkeypatch.chdir(tmp_path) + (tmp_path / "mutants").mkdir() + (tmp_path / "src").mkdir() + saved = state().tests_by_mangled_function_name + state().tests_by_mangled_function_name = defaultdict(set) + yield + state().tests_by_mangled_function_name = saved + + +class TestClearOutObsoleteTestNames: + def test_obsolete_test_names_are_dropped_and_persisted(self, capsys): + state().tests_by_mangled_function_name["pkg.foo.x_add"] |= { + "tests/test_foo.py::test_add", + "tests/test_gone.py::test_add", + } + state().tests_by_mangled_function_name["pkg.foo.x_sub"].add("tests/test_gone.py::test_sub") + + ListAllTestsResult(ids={"tests/test_foo.py::test_add"}).clear_out_obsolete_test_names() + + assert dict(state().tests_by_mangled_function_name) == { + "pkg.foo.x_add": {"tests/test_foo.py::test_add"}, + "pkg.foo.x_sub": set(), + } + assert "Removed 2 obsolete test names" in capsys.readouterr().out + on_disk = json.loads(STATS_FILE.read_text()) + assert on_disk["tests_by_mangled_function_name"] == { + "pkg.foo.x_add": ["tests/test_foo.py::test_add"], + "pkg.foo.x_sub": [], + } + + def test_nothing_obsolete_leaves_stats_file_untouched(self, capsys): + state().tests_by_mangled_function_name["pkg.foo.x_add"].add("tests/test_foo.py::test_add") + + ListAllTestsResult(ids={"tests/test_foo.py::test_add"}).clear_out_obsolete_test_names() + + assert dict(state().tests_by_mangled_function_name) == {"pkg.foo.x_add": {"tests/test_foo.py::test_add"}} + assert capsys.readouterr().out == "" + assert not STATS_FILE.exists() From 591a9df342a483d546ac46649209fd9ee84b30c3 Mon Sep 17 00:00:00 2001 From: t-roi <78104335+t-roi33@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:38:52 +0200 Subject: [PATCH 2/3] Also drop obsolete test names from duration_by_test clear_out_obsolete_test_names only pruned tests_by_mangled_function_name, so node IDs of deleted or moved tests stayed in duration_by_test in mutants/mutmut-stats.json for good. Prune both structures from one set of obsolete names, and report that set's size, which is what the message "Removed N obsolete test names" says. --- HISTORY.rst | 2 ++ src/mutmut/runners/harness.py | 18 +++++++++--------- tests/runners/test_harness.py | 19 +++++++++++++++++-- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 7b2b9b7d..d7510b31 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -6,6 +6,8 @@ Unreleased * Fix obsolete test names never being removed from ``mutants/mutmut-stats.json``: the counters guarding the save iterated dict keys instead of the test sets, so the cleanup was applied in memory but never persisted (`#564`) +* Also drop obsolete test names from ``duration_by_test`` in ``mutants/mutmut-stats.json``, not only from the test-to-function mapping + * Fix ``# pragma: no mutate block`` being silently ignored when placed on an ``else``, ``except``, ``except*`` or ``finally`` header, and on the ``try`` line of a ``try``/``except*`` * Fix mutants being reported as survived when a test uses ``patch.dict(os.environ, ..., clear=True)`` (`#511`) diff --git a/src/mutmut/runners/harness.py b/src/mutmut/runners/harness.py index f68863f4..5282263f 100644 --- a/src/mutmut/runners/harness.py +++ b/src/mutmut/runners/harness.py @@ -60,18 +60,18 @@ def __init__(self, *, ids: set[str]) -> None: self.ids = ids def clear_out_obsolete_test_names(self) -> None: - count_before = sum(len(x) for x in state().tests_by_mangled_function_name.values()) + known = collected_test_names().union(*state().tests_by_mangled_function_name.values()) + obsolete = known - self.ids + if not obsolete: + return state().tests_by_mangled_function_name = defaultdict( set, - **{ - k: {test_name for test_name in test_names if test_name in self.ids} - for k, test_names in state().tests_by_mangled_function_name.items() - }, + **{k: test_names - obsolete for k, test_names in state().tests_by_mangled_function_name.items()}, ) - count_after = sum(len(x) for x in state().tests_by_mangled_function_name.values()) - if count_before != count_after: - print(f"Removed {count_before - count_after} obsolete test names") - save_stats() + for test_name in obsolete: + state().duration_by_test.pop(test_name, None) + print(f"Removed {len(obsolete)} obsolete test names") + save_stats() def new_tests(self) -> set[str]: return self.ids - collected_test_names() diff --git a/tests/runners/test_harness.py b/tests/runners/test_harness.py index d6c36f0e..5fa3f7bf 100644 --- a/tests/runners/test_harness.py +++ b/tests/runners/test_harness.py @@ -19,10 +19,13 @@ def isolated_stats(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) (tmp_path / "mutants").mkdir() (tmp_path / "src").mkdir() - saved = state().tests_by_mangled_function_name + saved_tests = state().tests_by_mangled_function_name + saved_durations = state().duration_by_test state().tests_by_mangled_function_name = defaultdict(set) + state().duration_by_test = defaultdict(float) yield - state().tests_by_mangled_function_name = saved + state().tests_by_mangled_function_name = saved_tests + state().duration_by_test = saved_durations class TestClearOutObsoleteTestNames: @@ -46,6 +49,18 @@ def test_obsolete_test_names_are_dropped_and_persisted(self, capsys): "pkg.foo.x_sub": [], } + def test_obsolete_test_durations_are_dropped_and_persisted(self, capsys): + state().tests_by_mangled_function_name["pkg.foo.x_add"].add("tests/test_foo.py::test_add") + state().duration_by_test["tests/test_foo.py::test_add"] = 0.1 + state().duration_by_test["tests/test_gone.py::test_add"] = 0.2 + + ListAllTestsResult(ids={"tests/test_foo.py::test_add"}).clear_out_obsolete_test_names() + + assert dict(state().duration_by_test) == {"tests/test_foo.py::test_add": 0.1} + assert "Removed 1 obsolete test names" in capsys.readouterr().out + on_disk = json.loads(STATS_FILE.read_text()) + assert on_disk["duration_by_test"] == {"tests/test_foo.py::test_add": 0.1} + def test_nothing_obsolete_leaves_stats_file_untouched(self, capsys): state().tests_by_mangled_function_name["pkg.foo.x_add"].add("tests/test_foo.py::test_add") From 08119994467d3929e5c87cb73d56e4cf6f7458cd Mon Sep 17 00:00:00 2001 From: Nick Lafleur <55208706+nicklafleur@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:00:46 -0400 Subject: [PATCH 3/3] misc: history --- HISTORY.rst | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index d7510b31..5dff173b 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -4,9 +4,7 @@ Changelog Unreleased ~~~~~~~~~~ -* Fix obsolete test names never being removed from ``mutants/mutmut-stats.json``: the counters guarding the save iterated dict keys instead of the test sets, so the cleanup was applied in memory but never persisted (`#564`) - -* Also drop obsolete test names from ``duration_by_test`` in ``mutants/mutmut-stats.json``, not only from the test-to-function mapping +* Fix obsolete test names never being removed from ``mutants/mutmut-stats.json`` (`#564`) * Fix ``# pragma: no mutate block`` being silently ignored when placed on an ``else``, ``except``, ``except*`` or ``finally`` header, and on the ``try`` line of a ``try``/``except*``