Fixing Broken help() Output for Decorated Python Functions

You call help() on a function you decorated and the output is wrong. The name says wrapper. The parameter list shows (*args, **kwargs). The docstring you wrote is missing entirely. Everything about the help() output describes the inner function inside your decorator instead of the function you intended. This article explains exactly how help() constructs its output, why decorators break each piece, and three levels of fix ranging from a single line of code to a full third-party solution.

Python's built-in help() function is the primary way developers read documentation in interactive sessions. It is used constantly at the REPL, inside Jupyter notebooks, and through IDE hover tooltips that call the same underlying machinery. When decorators corrupt the output, every tool that relies on help() is affected: the interactive shell, automated documentation generators like Sphinx and pdoc, and the autocompletion systems in editors like VS Code and PyCharm.

How help() Builds Its Output

When you call help(some_function), Python constructs a formatted text block from three sources on the function object. Understanding these sources is essential to understanding why decorators break the output and what needs to be repaired.

The first line of help() output shows the function name and its module. Python reads __name__ for the displayed name and __module__ for the module reference. The second line shows the function's parameter list. Python calls inspect.signature() internally to introspect the function's parameters, default values, and annotations. Everything after the signature comes from __doc__, the function's docstring attribute.

Here is what correct help() output looks like for a well-documented function:

def calculate_compound_interest(principal, rate, years, n=12):
    """Calculate compound interest on a principal amount.

    Args:
        principal: The initial investment amount.
        rate: Annual interest rate as a decimal.
        years: Number of years to compound.
        n: Number of times compounded per year.

    Returns:
        The final amount after compounding.
    """
    return principal * (1 + rate / n) ** (n * years)

help(calculate_compound_interest)

That help() call produces:

Help on function calculate_compound_interest in module __main__:

calculate_compound_interest(principal, rate, years, n=12)
    Calculate compound interest on a principal amount.

    Args:
        principal: The initial investment amount.
        rate: Annual interest rate as a decimal.
        years: Number of years to compound.
        n: Number of times compounded per year.

    Returns:
        The final amount after compounding.

Three elements are visible: the function name (calculate_compound_interest) sourced from __name__, the parameter list ((principal, rate, years, n=12)) sourced from inspect.signature(), and the docstring sourced from __doc__. When all three are correct, help() provides exactly the information a developer needs to use the function.

Diagnosing What Goes Wrong

Now apply a decorator that does not preserve metadata and observe what happens to each piece:

def audit_log(func):
    def wrapper(*args, **kwargs):
        print(f"[AUDIT] {func.__name__} called")
        return func(*args, **kwargs)
    return wrapper

@audit_log
def calculate_compound_interest(principal, rate, years, n=12):
    """Calculate compound interest on a principal amount.

    Args:
        principal: The initial investment amount.
        rate: Annual interest rate as a decimal.
        years: Number of years to compound.
        n: Number of times compounded per year.

    Returns:
        The final amount after compounding.
    """
    return principal * (1 + rate / n) ** (n * years)

help(calculate_compound_interest)

The help() output is now:

Help on function wrapper in module __main__:

wrapper(*args, **kwargs)

Every piece is wrong. The name shows wrapper instead of calculate_compound_interest. The signature shows (*args, **kwargs) instead of the four specific parameters. The docstring is missing completely because wrapper has no docstring. A developer seeing this output has zero information about what the function does, what arguments it accepts, or what it returns.

Each broken piece traces back to a specific attribute on the function object:

help() Component Source Attribute Expected Value Broken Value
Function name __name__ 'calculate_compound_interest' 'wrapper'
Parameter list inspect.signature() (principal, rate, years, n=12) (*args, **kwargs)
Docstring __doc__ Full docstring text None
Module path __module__ Original module name Decorator's module name
Qualified name __qualname__ 'calculate_compound_interest' 'audit_log.<locals>.wrapper'

This is not a subtle bug. When an application has dozens of decorated functions and a developer calls help() on any of them, every result is identical: wrapper(*args, **kwargs) with no docstring. The help system becomes completely useless for any decorated function.

Three Levels of Fix

Level 1: functools.wraps (Standard Library)

The standard fix is functools.wraps. Adding one line to the decorator restores all three components of the help() output.

import functools

def audit_log(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"[AUDIT] {func.__name__} called")
        return func(*args, **kwargs)
    return wrapper

@audit_log
def calculate_compound_interest(principal, rate, years, n=12):
    """Calculate compound interest on a principal amount.

    Args:
        principal: The initial investment amount.
        rate: Annual interest rate as a decimal.
        years: Number of years to compound.
        n: Number of times compounded per year.

    Returns:
        The final amount after compounding.
    """
    return principal * (1 + rate / n) ** (n * years)

help(calculate_compound_interest)

The help() output is now fully restored:

Help on function calculate_compound_interest in module __main__:

calculate_compound_interest(principal, rate, years, n=12)
    Calculate compound interest on a principal amount.

    Args:
        principal: The initial investment amount.
        rate: Annual interest rate as a decimal.
        years: Number of years to compound.
        n: Number of times compounded per year.

    Returns:
        The final amount after compounding.

functools.wraps fixes each component through a different mechanism. It copies __name__ and __doc__ directly from the original function onto the wrapper. For the signature, it adds a __wrapped__ attribute pointing to the original function. When inspect.signature() encounters a function with __wrapped__, it follows that reference and returns the original function's parameter list instead of the wrapper's (*args, **kwargs). Since help() uses inspect.signature() internally, the correct signature appears in the output.

Note

The __wrapped__ attribute also lets you bypass the decorator entirely: calculate_compound_interest.__wrapped__(1000, 0.05, 10) calls the original function directly with no audit logging. This is valuable for unit tests where you want to verify core behavior without decorator side effects.

Fixing Parameterized Decorators

Decorators that accept their own arguments have three levels of nesting. The @functools.wraps(func) line must go on the innermost function, the one that replaces the original:

import functools

def retry(max_attempts=3):
    def decorator(func):
        @functools.wraps(func)          # correct: innermost function
        def wrapper(*args, **kwargs):
            last_error = None
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    last_error = e
            raise last_error
        return wrapper
    return decorator

@retry(max_attempts=5)
def fetch_data(url, timeout=30):
    """Fetch data from a remote URL.

    Args:
        url: The endpoint to request.
        timeout: Seconds before the request times out.

    Returns:
        The response body as a string.
    """
    import urllib.request
    with urllib.request.urlopen(url, timeout=timeout) as resp:
        return resp.read().decode()

help(fetch_data)
# Help on function fetch_data in module __main__:
#
# fetch_data(url, timeout=30)
#     Fetch data from a remote URL.
#     ...

Placing @functools.wraps(func) on the decorator function (the middle layer) is a common mistake. That copies metadata onto the wrong object. The function that callers interact with is wrapper, so wrapper is where the metadata must live.

Level 2: functools.update_wrapper for Class-Based Decorators

Class-based decorators implement the wrapper as a class with a __call__ method rather than a nested function. Since there is no inner function to apply @functools.wraps to, you call functools.update_wrapper directly in __init__:

import functools

class RateLimit:
    """Decorator that limits function calls per time window."""

    def __init__(self, func, max_calls=10):
        functools.update_wrapper(self, func)
        self.func = func
        self.max_calls = max_calls
        self.call_count = 0

    def __call__(self, *args, **kwargs):
        if self.call_count >= self.max_calls:
            raise RuntimeError("Rate limit exceeded")
        self.call_count += 1
        return self.func(*args, **kwargs)

@RateLimit
def send_notification(user_id, message):
    """Send a push notification to a user.

    Args:
        user_id: The target user's identifier.
        message: The notification text.
    """
    return f"Sent to {user_id}: {message}"

help(send_notification)
# Help on function send_notification in module __main__:
#
# send_notification(user_id, message)
#     Send a push notification to a user.
#     ...

functools.update_wrapper(self, func) copies __name__, __doc__, __qualname__, __module__, and __annotations__ from the original function onto the class instance, and adds the __wrapped__ reference. This is the same operation that functools.wraps performs, since wraps is implemented internally as a call to update_wrapper.

Level 3: The wrapt Library for Full Transparency

functools.wraps handles the common case well, but it has limitations. The wrapper function still exists as a separate object with its own (*args, **kwargs) signature at the bytecode level. The correct signature is only visible because inspect.signature() follows the __wrapped__ chain. Some older tools, static analyzers, and edge cases with @classmethod or @staticmethod stacking do not follow __wrapped__ correctly.

The third-party wrapt library solves this by creating transparent object proxies that delegate attribute access directly to the original function. Decorators built with wrapt are fully signature-preserving without relying on __wrapped__ at all.

import wrapt

@wrapt.decorator
def audit_log(wrapped, instance, args, kwargs):
    print(f"[AUDIT] {wrapped.__name__} called")
    return wrapped(*args, **kwargs)

@audit_log
def calculate_compound_interest(principal, rate, years, n=12):
    """Calculate compound interest on a principal amount.

    Args:
        principal: The initial investment amount.
        rate: Annual interest rate as a decimal.
        years: Number of years to compound.
        n: Number of times compounded per year.

    Returns:
        The final amount after compounding.
    """
    return principal * (1 + rate / n) ** (n * years)

help(calculate_compound_interest)
# Correct name, correct signature, correct docstring

import inspect
print(inspect.signature(calculate_compound_interest))
# (principal, rate, years, n=12)

The wrapt.decorator pattern takes four arguments: wrapped (the original function), instance (the bound instance if decorating a method, otherwise None), args, and kwargs. The instance parameter is what makes wrapt handle class methods, static methods, and descriptors correctly, a set of edge cases where functools.wraps alone can fall short.

Pro Tip

For library authors whose decorators will be applied to functions they do not control, wrapt is the safest choice because it handles every edge case in the Python object model. For application code where you control both the decorator and the decorated functions, functools.wraps is sufficient and has zero dependencies.

Verifying the Fix Programmatically

After fixing a decorator, you can verify that help() will produce correct output by checking each source attribute independently. This is useful in test suites to ensure that decorators never regress:

import inspect

def verify_decorator_transparency(decorated_func, expected_name):
    """Verify that a decorated function preserves its identity."""
    checks = {
        "__name__": decorated_func.__name__ == expected_name,
        "__doc__": decorated_func.__doc__ is not None,
        "__wrapped__": hasattr(decorated_func, "__wrapped__"),
        "signature": "args" not in str(inspect.signature(decorated_func)),
    }

    for check, passed in checks.items():
        status = "PASS" if passed else "FAIL"
        print(f"  [{status}] {check}")

    return all(checks.values())

# Test the fixed decorator
print("Verifying calculate_compound_interest:")
verify_decorator_transparency(
    calculate_compound_interest,
    "calculate_compound_interest"
)
# [PASS] __name__
# [PASS] __doc__
# [PASS] __wrapped__
# [PASS] signature

The signature check looks for 'args' in the string representation of the signature. If the signature is (*args, **kwargs), the check fails, indicating that inspect.signature() is not following __wrapped__ and the decorator is not properly transparent. This is a pragmatic heuristic: a function whose real parameters are named args would produce a false negative, but that is rare enough that this check works well in practice.

Customizing the Attributes That wraps Copies

By default, functools.wraps copies the attributes listed in functools.WRAPPER_ASSIGNMENTS: __module__, __name__, __qualname__, __annotations__, __type_params__, and __doc__. If you need additional attributes preserved, you can extend this tuple:

import functools

EXTENDED_ASSIGNMENTS = functools.WRAPPER_ASSIGNMENTS + (
    '__defaults__',
    '__kwdefaults__',
)

def preserve_defaults(func):
    @functools.wraps(func, assigned=EXTENDED_ASSIGNMENTS)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@preserve_defaults
def connect(host, port=5432, ssl=True):
    """Establish a database connection."""
    return f"Connected to {host}:{port} (ssl={ssl})"

print(connect.__defaults__)
# (5432, True)

Without the extended assigned tuple, connect.__defaults__ would return None because the wrapper function's own defaults are empty. With it, the original function's default values are accessible on the wrapper, which is important for tools that inspect defaults programmatically rather than through inspect.signature().

Key Takeaways

  1. help() reads three sources from the function object. It gets the name from __name__, the parameter list from inspect.signature(), and the docstring from __doc__. Decorators break all three by replacing the original function with a wrapper that has its own values for each attribute.
  2. functools.wraps is the standard fix. Apply @functools.wraps(func) to the innermost wrapper function in your decorator. It copies __name__, __doc__, __qualname__, __module__, __annotations__, and __type_params__, and adds a __wrapped__ reference that inspect.signature() follows to retrieve the correct parameter list.
  3. For class-based decorators, call functools.update_wrapper(self, func) in __init__. This is the function that functools.wraps calls internally, adapted for use when there is no inner function to decorate.
  4. For library code with edge cases, consider the wrapt library. It creates transparent object proxies that preserve signatures at the bytecode level and correctly handle @classmethod, @staticmethod, and descriptor protocols. Use it when your decorator will be applied to functions you do not control.
  5. Verify decorator transparency in tests. Check that __name__, __doc__, __wrapped__, and inspect.signature() all reflect the original function. Catching metadata loss in automated tests prevents it from reaching production, where broken help() output erodes developer trust in the entire codebase.

Correct help() output is not a cosmetic concern. It is the primary way developers learn how to use functions in a codebase. When every decorated function in a project produces the same generic wrapper(*args, **kwargs) output with no docstring, the documentation system is effectively offline. The fix costs one line of code per decorator. There is no reason to ship a decorator without it.