---
title: Decorator Handling — irradiate Internals
description: How irradiate handles Python decorators like @property, @classmethod, and @staticmethod during mutation testing.
---

# 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

```python
# 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:

```python
@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:

```python
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:

```python
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:

```python
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.overload` has no runtime effect (type-checker only). Functions decorated with `@overload` have empty bodies (`...`), so there's nothing to mutate. irradiate skips them because they produce zero mutations.
- `@abstractmethod` marks abstract methods for ABC enforcement. The function body is still real code. Currently skipped but could be handled like a regular instance method.
- `@functools.wraps` copies metadata inside other decorators. No runtime behavior change.

### Non-descriptor decorators: source-patch path

Decorators with definition-time side effects or complex wrapping behavior cannot use the trampoline. These are now handled via **source-patching**: irradiate writes a patched copy of the source file with a single mutation applied and runs tests in a subprocess.

This covers 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.

Source-patch mutations include:
- **Body mutations**: the same operators as trampoline (binop, return_value, etc.) applied to the function body
- **`decorator_removal`**: each decorator is a separate mutant where that decorator line is removed entirely

Use `--no-source-patch` to disable source-patching and only mutate trampoline-compatible functions.

## Real-world impact

Decorator frequency measured across 5 popular Python projects:

| Project | Functions | Decorated | @property/@classmethod/@staticmethod | Source-patched |
|---------|-----------|-----------|--------------------------------------|----------------|
| 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 source-patching, irradiate now covers all decorated functions in these codebases.

## 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) handles 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.
