Skip to content

Commit b539531

Browse files
committed
feat: invalidate cache on config and dependency changes
Cached verdicts were only invalidated when a function body changed, so changes to config or dependency files silently produced stale results. - Config.config_fingerprint() hashes result-affecting config, grouped so we reset only what each change can affect: - timeout change -> reset only timeout verdicts - type_check_command change -> reset mutants whose type-check status flips (symmetric difference of old exit-37 and newly-caught) - pytest_add_cli_args / test-selection change -> reset all results and force full stats recollection - set-affecting config (source_paths, only_mutate, ...) is ignored: new mutants are uncached and dropped ones stop being walked - compute_watched_file_hashes() hashes dependency/build files (pyproject.toml, setup.cfg/py, requirements*.txt, lockfiles) plus user globs from the new cache_invalidation_files config. The on_dependency_change config ("warn" | "rerun" | "ignore", default "warn") controls whether a change warns or resets all results. - Fingerprints persist in mutmut-stats.json with pop-with-default, so old caches load and a missing fingerprint triggers no invalidation.
1 parent 7c3ecb0 commit b539531

6 files changed

Lines changed: 414 additions & 5 deletions

File tree

README.rst

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,49 @@ You can add and override pytest arguments:
401401
also_copy = ["mutmut_pytest.ini"]
402402
403403
404+
Detecting dependency and config changes
405+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
406+
407+
Between runs, mutmut only re-tests mutants in functions whose source changed.
408+
Changes outside your Python source — a dependency upgrade, a data file, a
409+
config file — cannot be tied to a function, so they would otherwise be missed
410+
and you would get cached results that no longer reflect reality.
411+
412+
To catch this, mutmut hashes a set of build and dependency files and warns you
413+
when any of them change since the last run. By default it watches:
414+
415+
- `pyproject.toml`
416+
- `setup.cfg`
417+
- `setup.py`
418+
- `requirements*.txt`
419+
- `poetry.lock`
420+
- `uv.lock`
421+
- `Pipfile`
422+
- `Pipfile.lock`
423+
424+
You can watch additional files (for example data files your tests depend on)
425+
with the `cache_invalidation_files` config, which accepts glob patterns
426+
resolved against the project root:
427+
428+
.. code-block:: toml
429+
430+
cache_invalidation_files = [ "queries/*.sql", "config/*.yaml" ]
431+
432+
When a watched file changes, `on_dependency_change` controls what happens:
433+
434+
- `warn` (default): list the changed files and keep the cache.
435+
- `rerun`: re-test all mutants.
436+
- `ignore`: do nothing.
437+
438+
.. code-block:: toml
439+
440+
on_dependency_change = "warn"
441+
442+
Changes to mutmut's own result-affecting config (such as `pytest_add_cli_args`,
443+
`type_check_command`, or the timeout settings) are always detected and
444+
invalidate the affected cached results automatically.
445+
446+
404447
Unstable configs
405448
~~~~~~~~~~~~~~~~
406449

src/mutmut/__main__.py

Lines changed: 134 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import ast
2323
import fnmatch
2424
import gc
25+
import hashlib
2526
import inspect
2627
import itertools
2728
import json
@@ -62,6 +63,7 @@
6263
from mutmut.code_coverage import get_covered_lines_for_file
6364
from mutmut.configuration import Config
6465
from mutmut.mutation.data import SourceFileMutationData
66+
from mutmut.mutation.file_mutation import FailedTypeCheckMutant
6567
from mutmut.mutation.file_mutation import filter_mutants_with_type_checker
6668
from mutmut.mutation.file_mutation import mutate_file_contents
6769
from mutmut.mutation.trampoline_templates import CLASS_NAME_SEPARATOR
@@ -819,10 +821,130 @@ def _invalidate_stale_dependency_edges() -> set[str]:
819821
return changed_functions
820822

821823

822-
def collect_or_load_stats(runner: TestRunner, invalidate_stale_callers: bool = True) -> None:
824+
# Dependency / build files whose changes the per-function source hashes cannot see.
825+
# Globs are resolved against the project root; missing files are skipped. Users can
826+
# extend this via the ``cache_invalidation_files`` config.
827+
_DEFAULT_WATCHED_FILES = (
828+
"pyproject.toml",
829+
"setup.cfg",
830+
"setup.py",
831+
"requirements*.txt",
832+
"poetry.lock",
833+
"uv.lock",
834+
"Pipfile",
835+
"Pipfile.lock",
836+
)
837+
838+
839+
def compute_watched_file_hashes() -> dict[str, str]:
840+
"""Map watched-file path -> content hash for the default set plus user globs."""
841+
patterns = list(_DEFAULT_WATCHED_FILES) + list(Config.get().cache_invalidation_files)
842+
hashes: dict[str, str] = {}
843+
for pattern in patterns:
844+
for path in sorted(Path(".").glob(pattern)):
845+
if path.is_file():
846+
hashes[str(path)] = hashlib.sha256(path.read_bytes()).hexdigest()[:12]
847+
return hashes
848+
849+
850+
def _reset_mutant_results(should_reset: Callable[[str, int], bool]) -> int:
851+
"""Reset cached verdicts to ``None`` (forcing a re-test) where ``should_reset`` holds.
852+
853+
``should_reset`` only sees already-decided mutants (``exit_code`` is not ``None``).
854+
"""
855+
count = 0
856+
for path in walk_mutatable_files():
857+
meta_path = Path("mutants") / (str(path) + ".meta")
858+
if not meta_path.exists():
859+
continue
860+
m = SourceFileMutationData(path=path)
861+
m.load()
862+
dirty = False
863+
for key, exit_code in list(m.exit_code_by_key.items()):
864+
if exit_code is not None and should_reset(key, exit_code):
865+
m.exit_code_by_key[key] = None
866+
dirty = True
867+
count += 1
868+
if dirty:
869+
m.save()
870+
return count
871+
872+
873+
def _report_watched_file_changes() -> bool:
874+
"""Surface changes to watched config/dependency files.
875+
876+
Returns True only when the configured policy is ``rerun`` and something changed,
877+
asking the caller to reset all results. Silent when no prior hashes exist.
878+
"""
879+
old = state().old_watched_file_hashes
880+
if not old:
881+
return False
882+
new = compute_watched_file_hashes()
883+
changed = sorted(p for p in old.keys() | new.keys() if old.get(p) != new.get(p))
884+
if not changed:
885+
return False
886+
887+
policy = Config.get().on_dependency_change
888+
if policy == "ignore":
889+
return False
890+
if policy == "rerun":
891+
print(f" {len(changed)} watched file(s) changed; rerunning all mutants: {', '.join(changed)}")
892+
return True
893+
# default: warn but keep the cache
894+
print(f" Warning: {len(changed)} watched file(s) changed since the last run: {', '.join(changed)}")
895+
print(" These cannot be tracked for behavioral changes, so cached results were kept.")
896+
print(' If the changes affect your tests, delete the mutants/ directory or set on_dependency_change = "rerun".')
897+
return False
898+
899+
900+
def _apply_config_change_invalidation(mutants_caught_by_type_checker: dict[str, object]) -> bool:
901+
"""Reset only the cached verdicts a config / dependency change could have invalidated.
902+
903+
Returns True if a full stats recollection is required (a global pytest config change
904+
or an opt-in dependency rerun), in which case all results have already been reset.
905+
"""
906+
old_fp = state().old_config_fingerprint
907+
new_fp = Config.get().config_fingerprint()
908+
changed_groups = {g for g in new_fp if old_fp.get(g) != new_fp[g]} if old_fp else set()
909+
910+
dependency_rerun = _report_watched_file_changes()
911+
912+
# Global groups change how *every* test runs / which tests map to a function, so no
913+
# subset of results is safe to keep -> full reset and full stats recollection.
914+
if changed_groups & {"test_execution", "test_selection"} or dependency_rerun:
915+
_reset_mutant_results(lambda key, exit_code: True)
916+
mutmut.duration_by_test.clear()
917+
mutmut.tests_by_mangled_function_name.clear()
918+
state().function_dependencies.clear()
919+
return True
920+
921+
# Timeout config only reclassifies timeouts; keep every other verdict.
922+
if "timeout" in changed_groups:
923+
_reset_mutant_results(lambda key, exit_code: status_by_exit_code[exit_code] == "timeout")
924+
925+
# The type-check pre-filter runs fresh every run; only verdicts whose type-check
926+
# status flips are stale -> reset the symmetric difference of old (==37) and new.
927+
if "type_check" in changed_groups:
928+
caught = set(mutants_caught_by_type_checker)
929+
_reset_mutant_results(lambda key, exit_code: (exit_code == 37) != (key in caught))
930+
931+
return False
932+
933+
934+
def collect_or_load_stats(
935+
runner: TestRunner,
936+
*,
937+
mutants_caught_by_type_checker: dict[str, Any] | None = None,
938+
apply_config_invalidation: bool = False,
939+
invalidate_stale_callers: bool = True,
940+
) -> None:
823941
did_load = load_stats()
824942

825-
if not did_load:
943+
force_full = False
944+
if did_load and apply_config_invalidation:
945+
force_full = _apply_config_change_invalidation(mutants_caught_by_type_checker or {})
946+
947+
if not did_load or force_full:
826948
# Run full stats
827949
run_stats_collection(runner)
828950
else:
@@ -862,6 +984,8 @@ def load_stats() -> bool:
862984
state().old_function_hashes = data.pop("function_hashes", {})
863985
for k, v in data.pop("function_dependencies", {}).items():
864986
state().function_dependencies[k] = set(v)
987+
state().old_config_fingerprint = data.pop("config_fingerprint", {})
988+
state().old_watched_file_hashes = data.pop("watched_file_hashes", {})
865989
assert not data, data
866990
did_load = True
867991
except (FileNotFoundError, JSONDecodeError):
@@ -878,6 +1002,8 @@ def save_stats() -> None:
8781002
stats_time=mutmut.stats_time,
8791003
function_hashes=state().current_function_hashes,
8801004
function_dependencies={k: list(v) for k, v in state().function_dependencies.items()},
1005+
config_fingerprint=Config.get().config_fingerprint(),
1006+
watched_file_hashes=compute_watched_file_hashes(),
8811007
),
8821008
f,
8831009
indent=4,
@@ -1101,11 +1227,10 @@ def _run(mutant_names: tuple[str, ...] | list[str], max_children: int | None) ->
11011227
f" done in {round(time.total_seconds() * 1000)}ms ({stats.mutated} files mutated, {stats.ignored} ignored, {stats.unmodified} unmodified)",
11021228
)
11031229

1230+
mutants_caught_by_type_checker: dict[str, FailedTypeCheckMutant] = {}
11041231
if Config.get().type_check_command:
11051232
with CatchOutput(spinner_title="Filtering mutations with type checker"):
11061233
mutants_caught_by_type_checker = filter_mutants_with_type_checker()
1107-
else:
1108-
mutants_caught_by_type_checker = {}
11091234

11101235
# TODO: config/option for runner
11111236
# runner = HammettRunner()
@@ -1114,7 +1239,11 @@ def _run(mutant_names: tuple[str, ...] | list[str], max_children: int | None) ->
11141239

11151240
# TODO: run these steps only if we have mutants to test
11161241

1117-
collect_or_load_stats(runner)
1242+
collect_or_load_stats(
1243+
runner,
1244+
mutants_caught_by_type_checker=mutants_caught_by_type_checker,
1245+
apply_config_invalidation=True,
1246+
)
11181247

11191248
mutants, source_file_mutation_data_by_path = collect_source_file_mutation_data(mutant_names=mutant_names)
11201249

src/mutmut/configuration.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import fnmatch
4+
import hashlib
45
import os
56
import platform
67
import sys
@@ -144,6 +145,8 @@ def _load_config() -> Config:
144145
), # False on Mac, true otherwise as default (https://github.com/boxed/mutmut/pull/450#issuecomment-4002571055)
145146
track_dependencies=s("track_dependencies", True),
146147
dependency_tracking_depth=s("dependency_tracking_depth", None),
148+
cache_invalidation_files=s("cache_invalidation_files", []),
149+
on_dependency_change=s("on_dependency_change", "warn"),
147150
)
148151

149152

@@ -168,6 +171,31 @@ class Config:
168171
use_setproctitle: bool
169172
track_dependencies: bool
170173
dependency_tracking_depth: int | None
174+
cache_invalidation_files: list[str]
175+
on_dependency_change: str
176+
177+
def config_fingerprint(self) -> dict[str, str]:
178+
"""Hash the config fields that can change cached mutant *results*, grouped so the
179+
caller can invalidate only the verdict classes each group can affect.
180+
181+
Fields that only change *which* mutants exist (source_paths, only_mutate, etc.)
182+
are deliberately excluded: new mutants are born uncached and dropped ones simply
183+
stop being walked, so they need no result invalidation.
184+
"""
185+
186+
def _hash(value: object) -> str:
187+
return hashlib.sha256(repr(value).encode()).hexdigest()[:12]
188+
189+
return {
190+
# global pytest behaviour: a change can flip any verdict
191+
"test_execution": _hash(tuple(self.pytest_add_cli_args)),
192+
# which tests cover which function: a change reshapes the stats mapping
193+
"test_selection": _hash(tuple(self.pytest_add_cli_args_test_selection)),
194+
# only reclassifies timeouts
195+
"timeout": _hash((self.timeout_multiplier, self.timeout_constant)),
196+
# only changes the type-check pre-filter
197+
"type_check": _hash(tuple(self.type_check_command)),
198+
}
171199

172200
def should_mutate(self, path: Path | str) -> bool:
173201
return self._should_include_for_mutation(path) and not self._should_ignore_for_mutation(path)

src/mutmut/state.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ class MutmutState:
88
old_function_hashes: dict[str, str] = field(default_factory=dict)
99
current_function_hashes: dict[str, str] = field(default_factory=dict)
1010
function_dependencies: defaultdict[str, set[str]] = field(default_factory=lambda: defaultdict(set))
11+
# Fingerprints loaded from the previous run, used to detect config / dependency
12+
# changes the per-function source hashes cannot see. Empty when absent (pre-upgrade
13+
# cache or first run), in which case no invalidation is triggered.
14+
old_config_fingerprint: dict[str, str] = field(default_factory=dict)
15+
old_watched_file_hashes: dict[str, str] = field(default_factory=dict)
1116

1217

1318
_state: MutmutState | None = None

0 commit comments

Comments
 (0)