Decorator Handling in irradiate¶
irradiate uses a trampoline architecture to test multiple mutant variants within a single Python process. This page explains how decorators interact with that architecture, which decorators irradiate can handle, and why.
Background: the trampoline model¶
For each function, irradiate generates: - A renamed original (x_add__irradiate_orig) - One renamed variant per mutation (x_add__irradiate_1, x_add__irradiate_2, ...) - A lookup dict mapping variant names to functions - A wrapper function with the original name that dispatches to the correct variant at runtime
# Original source
def add(a, b):
return a + b
# After trampolining (simplified)
def x_add__irradiate_orig(a, b):
return a + b
def x_add__irradiate_1(a, b):
return a - b # mutant
x_add__irradiate_mutants = {'x_add__irradiate_1': x_add__irradiate_1}
def add(a, b):
return _irradiate_trampoline(x_add__irradiate_orig, x_add__irradiate_mutants, (a, b), {}, None)
The wrapper has the original name and signature, so callers see no difference. The trampoline checks irradiate_harness.active_mutant to decide which variant to invoke.
Why decorators are hard¶
Decorators execute at definition time (when the module is imported), not at call time. When irradiate replaces a function with a trampoline wrapper, the decorator wraps the wrapper, not the original function. This causes problems for decorators with side effects:
@app.route("/users") # registers the wrapper as the /users handler
def get_users(): # ← this is now the trampoline wrapper
return _irradiate_trampoline(...)
Specific failure modes: 1. Registration side effects: @app.route() registers the function in a URL map. If variants also had the decorator, you'd get duplicate registrations. 2. Descriptor protocol: @property turns the function into a descriptor. The wrapper must also be a property, or attribute access like obj.name breaks. 3. Calling convention changes: @classmethod passes cls instead of self; @staticmethod passes neither. The wrapper's forwarding logic must match.
What irradiate handles¶
The Big Three: @property, @classmethod, @staticmethod¶
These three stdlib decorators account for ~80% of all decorated functions in real Python projects (measured across click, httpx, flask, rich, pydantic). They are special because:
- They have no definition-time side effects — they only change the calling convention
- Their behavior is completely predictable — they're part of the Python data model
- They are stable — their semantics haven't changed since Python 2
irradiate handles these by keeping the decorator on the wrapper and adjusting the trampoline dispatch:
For @classmethod, the wrapper receives cls as its first argument. Variant lookup uses cls. to resolve mangled names through the MRO:
class Foo:
@classmethod
def make(cls, n):
return _irradiate_trampoline(
cls.xǁFooǁmake__irradiate_orig,
cls.xǁFooǁmake__irradiate_mutants,
(n,), {}, cls
)
For @staticmethod, the wrapper receives no implicit argument. Variant lookup uses the class name directly:
class Foo:
@staticmethod
def helper(x):
return _irradiate_trampoline(
Foo.xǁFooǁhelper__irradiate_orig,
Foo.xǁFooǁhelper__irradiate_mutants,
(x,), {}, None
)
For @property, the wrapper is a property whose getter dispatches through the trampoline. Only the getter is trampolined; setter/deleter decorators on the same property are preserved as-is:
class Foo:
@property
def name(self):
return _irradiate_trampoline(
_type(self).xǁFooǁname__irradiate_orig,
_type(self).xǁFooǁname__irradiate_mutants,
(), {}, self
)
Other "safe" decorators¶
Some decorators are effectively no-ops at runtime and don't interfere with trampolining:
@typing.overloadhas no runtime effect (type-checker only). Functions decorated with@overloadhave empty bodies (...), so there's nothing to mutate. irradiate skips them because they produce zero mutations.@abstractmethodmarks abstract methods for ABC enforcement. The function body is still real code. Currently skipped but could be handled like a regular instance method.@functools.wrapscopies metadata inside other decorators. No runtime behavior change.
Decorators that remain skipped¶
Decorators with definition-time side effects or complex wrapping behavior are still skipped. This includes registration decorators (@app.route(), @click.command(), @pytest.fixture), caching decorators (@lru_cache, @cache, @cached_property), wrapping decorators (@contextmanager, @retry, @login_required), and any user-defined decorators.
These require a different execution model (source-patching) that avoids the trampoline entirely. See GitHub issue #13 for the design.
Real-world impact¶
Decorator frequency measured across 5 popular Python projects:
| Project | Functions | Decorated | @property/@classmethod/@staticmethod | Remaining skipped |
|---|---|---|---|---|
| click | 527 | 65 (12%) | 23 | 42 (8%) |
| httpx | 446 | 78 (17%) | 62 | 16 (4%) |
| flask | 367 | 82 (22%) | 15 | 67 (18%) |
| rich | 911 | 206 (23%) | 162 | 44 (5%) |
| pydantic | 1854 | 438 (24%) | 288 | 150 (8%) |
With the Big Three handled, irradiate covers 92-96% of functions in most codebases (flask is an outlier at 82% due to heavy use of @setupmethod).
Why not source-patching for everything?¶
Source-patching (writing a modified copy of the file with one mutation applied, running tests in a fresh subprocess) would handle all decorators correctly. But it's slower:
- Trampoline mode: amortize pytest startup across hundreds of mutants in one session
- Source-patching: one subprocess per mutant (~1-3s overhead each)
For a codebase with 500 mutants, trampoline mode might take 60s total. Source-patching the same set would take ~1000s. The trampoline is irradiate's primary performance advantage over tools like cosmic-ray that use source-patching exclusively.
The hybrid approach — trampoline for the common case, source-patching for the long tail — gives the best of both worlds.