Repository navigation
Conversation
… keep only binding decorators Follows the direction proposed in boxed#387 (Otto-AA, 7 Mar. 2026). The copies (original and mutants) carry only staticmethod/classmethod; every other decorator wraps the trampoline alone, so it runs once, never wraps a copy, and its arguments are still not mutated. Still skipped: decorators that define several functions under one name (overload, x.setter/getter/deleter, f.register), and caching decorators (cache, lru_cache, cached_property), whose cache on the trampoline would answer every mutant from the parent process (false survivors, measured on the e2e cached_fibonacci).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #387. Supersedes #620 (
@overrideonly), which this covers.Problem
A decorated function is never mutated, except one with a lone
@staticmethod/@classmethod.@property,@contextmanager,@override,@abstractmethod, registration decorators (@app.get(...), a plugin registry) all take the function out of mutation testing silently: the score stays at 100% while those bodies are never measured. On the codebase I work on, 318 of 2,814 functions on the mutation surface were skipped this way, 73 of them with real branches.Change
The approach proposed in @Otto-AA in #387 (7 Mar. 2026): the decorators stay on the trampoline only, and the copies (the original and every mutant) keep only the decorators that decide how they bind --
staticmethod/classmethod, which the dict dispatch needs to call them alike. Against the three reasons in the source comment:@app.post("/foo"), a registry) runs once, on the trampoline, exactly as without mutmut.@propertysees the trampoline, a plain function, never a copy.is_classmethodis detected among all decorators (it was only checked for a lone one), so@deco @classmethodstill binds to the subclass.Still skipped:
@overload,@x.setter/.getter/.deleter,@f.register-- since their copies would collide in the mangled names;cache,lru_cache,cached_property). Measured one2e_projects/my_lib: with the cache on the trampoline, all 9 mutants ofcached_fibonaccisurvive althoughtest_my_libkills most of them -- the parent process fills the cache during the stats run, and every forked mutant answers from it. A per-copy cache (decorating the copies instead of the trampoline) would fix that, but changes caching semantics; I left it out to keep this reviewable and am happy to follow up if you want it.Tests
test_decorated_bodies_are_mutated_but_never_their_decoratorsreplacestest_do_not_mutate_top_level_decorators: decorator lines intact, bodies mutated.exec) tests: a@propertystays a property and its mutants run;@contextmanager; a registering decorator runs once and registers the trampoline, which reaches the active mutant;@deco @classmethodbinds to the subclass; an@overridemethod.Point.skip_multi_decorator's 8 mutants, all killed (the fixture'sskip_name no longer describes it -- happy to rename it here if you prefer).Suite: 473 passed / 4 skipped on
main, 490 / 4 with the change;pre-commit run --all-filespasses.I also ran a backport of the patch on 3.8.0 against ~10 modules of a production codebase: about 72 previously hidden mutants were measured and killed, nothing broke, and the 4 survivors were all genuine (3 equivalent mutants from a redundant condition, 1 missing test).