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.

Python Pop Quiz
What does Python's help() use to build the parameter list shown in its output?

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.

Python Pop Quiz
You write a decorator but forget @functools.wraps(func). A colleague calls help(your_decorated_function). What will the output show for the function name?

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().

What the Documentation Does Not Spell Out

The standard documentation for functools.wraps covers what it copies and what it adds. What it does not address in detail are the edge cases and internal mechanics that matter once you are writing decorators for production use or library distribution.

The __wrapped__ Chain Can Loop

When two or more decorators are stacked, each one that applies functools.wraps adds its own __wrapped__ attribute pointing to the function it received. The result is a linked chain: the outermost wrapper points to the second wrapper, the second wrapper points to the original. inspect.signature() follows this chain until it either reaches a function without __wrapped__ or hits an internal recursion limit.

The recursion limit is the problem. If a decorator erroneously wraps a function around itself — for example, wrapping the already-wrapped object rather than the original — you can produce a circular __wrapped__ chain. inspect.signature() will raise a ValueError: wrapper loop when unwrapping rather than returning silently or looping forever. That error is correct and intentional, but it can be confusing if you encounter it without understanding why it fires.

import functools, inspect

def broken_decorator(func):
    # Bug: wraps the already-decorated function, not the original
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    wrapper.__wrapped__ = wrapper  # accidental self-reference
    return wrapper

@broken_decorator
def my_func():
    """Docstring."""
    pass

try:
    inspect.signature(my_func)
except ValueError as e:
    print(e)
# ValueError: wrapper loop when unwrapping my_func

The detection logic inside CPython's inspect.py tracks every object it visits while following __wrapped__ and raises ValueError the moment it sees a repeat. You are unlikely to trigger this accidentally, but automated code generation tools and decorator factories that conditionally re-apply wrappers can produce it. The diagnostic is clear: check the __wrapped__ attribute on each layer of the decorated function and ensure no object points back to itself or to an earlier object in the chain.

WRAPPER_ASSIGNMENTS vs WRAPPER_UPDATES

functools.update_wrapper accepts two parameters that control its behavior: assigned and updated. The assigned parameter controls which attributes are copied with setattr — these replace whatever the wrapper already has. The updated parameter controls which attributes are merged with update — these extend the wrapper's existing value rather than replacing it.

The default for updated is functools.WRAPPER_UPDATES, which is the single-item tuple ('__dict__',). This means any custom attributes stored in the original function's __dict__ are merged into the wrapper's __dict__. Attributes set directly on the original function (for example, a func.cache = {} you added manually) survive the wrapping.

import functools

def my_func():
    """Original function."""
    pass

# Attach a custom attribute to the original
my_func.route = "/api/data"
my_func.requires_auth = True

def passthrough(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

decorated = passthrough(my_func)

# Custom attributes survive because __dict__ is merged, not replaced
print(decorated.route)          # '/api/data'
print(decorated.requires_auth)  # True
print(decorated.__wrapped__)    # <function my_func ...>

This behavior is what makes decorator stacking work cleanly in frameworks like Flask and FastAPI, where route decorators attach metadata to function objects. If functools.wraps replaced __dict__ rather than merging it, every piece of framework metadata would be lost when a second decorator was applied.

Note

You can pass updated=() to functools.wraps to disable the __dict__ merge entirely. This is occasionally useful when the wrapper intentionally replaces all framework metadata — for example, a decorator that re-registers a function under a different route.

How inspect.signature() Actually Follows __wrapped__

The CPython implementation of inspect.signature() does not follow __wrapped__ unconditionally. It follows the chain only when the current callable does not have its own __signature__ attribute. If a wrapper manually sets wrapper.__signature__ = some_sig, the chain walk stops there and inspect.signature() returns that explicit signature, regardless of what __wrapped__ points to.

This gives you a second mechanism for fixing help() output that does not require functools.wraps at all: manually construct a Signature object and assign it to __signature__.

import inspect, functools

def enforce_signature(func):
    """Decorator that hard-codes the displayed signature regardless of wrapping."""
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    # Manually assign the original's signature to the wrapper
    wrapper.__signature__ = inspect.signature(func)
    wrapper.__name__ = func.__name__
    wrapper.__doc__ = func.__doc__
    return wrapper

@enforce_signature
def query_db(host, port=5432, db="main"):
    """Query the database."""
    pass

print(inspect.signature(query_db))
# (host, port=5432, db='main')
# Note: no __wrapped__ attribute is present, yet the signature is correct

Setting __signature__ explicitly is also the technique used by libraries that produce truly synthetic signatures — for example, a decorator that merges the parameters of two functions into a combined callable. In those cases the signature does not correspond to any single underlying function, so there is no __wrapped__ chain to follow. The __signature__ attribute is the only correct tool.

Python's Own Documentation on the Purpose

The Python documentation for functools.update_wrapper states the intent directly. The official functools docs describe its primary purpose as supporting decorator functions that wrap and return other callables, noting that without it, the wrapper's own name and docstring would appear in place of the original function's. The documentation frames this as a documentation problem, but as this article has shown, it is equally a tooling problem: any system that calls inspect.signature() is affected, not just help().

The Difference Between __qualname__ and __name__ in Stack Traces

functools.wraps copies both __name__ and __qualname__, but they serve different purposes. __name__ is the simple name shown by help(). __qualname__ is the dotted path that appears in tracebacks and repr() output. Without copying __qualname__, a decorated method's traceback entry would show ClassName.audit_log.<locals>.wrapper instead of ClassName.method_name.

import functools

class DataPipeline:
    def audit_log(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            return func(*args, **kwargs)
        return wrapper

    @audit_log
    def run(self, config):
        """Execute the pipeline with the given config."""
        pass

# With functools.wraps:
print(DataPipeline.run.__qualname__)    # 'DataPipeline.run'
print(DataPipeline.run.__name__)        # 'run'

# Without functools.wraps, both would show:
# 'DataPipeline.audit_log..wrapper' and 'wrapper'

In production systems where logs and Sentry-style error trackers display __qualname__, a missing functools.wraps in a class-level decorator causes every decorated method to appear in error reports under the same anonymous wrapper path. Correlating exceptions to specific methods becomes impossible without manually matching stack frames to source files.

Using __wrapped__ for Selective Bypass in Tests

The __wrapped__ attribute is not only useful for inspect.signature(). It is a standard interface for bypassing the decorator entirely. When writing unit tests for a function that has side-effecting decorators (logging, rate limiting, authentication checks), you can call func.__wrapped__ directly to test the core logic without triggering decorator behavior.

import functools

def require_auth(func):
    @functools.wraps(func)
    def wrapper(request, *args, **kwargs):
        if not request.get("authenticated"):
            raise PermissionError("Not authenticated")
        return func(request, *args, **kwargs)
    return wrapper

@require_auth
def get_profile(request, user_id):
    """Return user profile data."""
    return {"user_id": user_id, "name": "Test User"}

# In production: authentication is enforced
try:
    get_profile({"authenticated": False}, user_id=42)
except PermissionError as e:
    print(e)  # Not authenticated

# In tests: bypass the decorator to test core logic directly
result = get_profile.__wrapped__({"authenticated": False}, user_id=42)
print(result)  # {'user_id': 42, 'name': 'Test User'}
# No PermissionError — __wrapped__ calls the original function directly

This pattern is particularly useful when the decorator requires infrastructure — a database connection, a live HTTP service, a message queue — that should not be present in unit tests. The alternative of mocking that infrastructure just to satisfy the decorator adds complexity that __wrapped__ eliminates entirely.

For functions with multiple decorator layers, __wrapped__ only peels one layer at a time. To reach the innermost original function, use inspect.unwrap(), which follows the entire chain:

import inspect, functools

def decorator_a(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

def decorator_b(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@decorator_a
@decorator_b
def target():
    """The original function."""
    pass

# __wrapped__ peels one layer
print(target.__wrapped__)             # the decorator_b wrapper
print(target.__wrapped__.__wrapped__) # the original target

# inspect.unwrap() follows the full chain
print(inspect.unwrap(target))         # the original target
print(inspect.unwrap(target).__doc__) # 'The original function.'

Version-Specific Behavior You Need to Know

The behavior of functools.wraps and inspect.signature() has changed across Python versions in ways that are not prominently documented outside of changelogs and CPython issue trackers. The version history below is sourced directly from the official Python documentation and the CPython issue tracker.

Python 3.2: The __wrapped__ attribute was added by functools.update_wrapper. Before 3.2, functools.wraps copied metadata attributes but did not add __wrapped__. The __annotations__ attribute was also added to WRAPPER_ASSIGNMENTS in this version. Missing attributes on the wrapped object no longer trigger AttributeError — they are silently skipped.

Python 3.4: The __wrapped__ attribute behavior was tightened (bpo-17482). Prior to 3.4, if the original function itself already had a __wrapped__ attribute, the copy operation could produce unexpected aliasing in deep decorator stacks. From 3.4 onward, the Python docs confirm that __wrapped__ always points to the function that was most recently wrapped, even when the source already carried that attribute — closing a subtle aliasing bug. inspect.unwrap() was also added in 3.4 to follow the full chain to the innermost function.

Python 3.12: __type_params__ was added to WRAPPER_ASSIGNMENTS. This attribute stores the type parameters of generic functions introduced by PEP 695 (the [T] syntax for generic classes and functions). If you write decorators that wrap generic functions using the new syntax, the type parameters are now copied automatically. On Python 3.11 and earlier this attribute does not exist and the copy is a no-op. There is a known edge case: when functools.update_wrapper is applied to a non-function callable and the source is a built-in type, the __type_params__ copy raises TypeError because type.__type_params__ is a descriptor, not a plain attribute (cpython issue #119011). The workaround is to pass a custom assigned tuple that excludes '__type_params__' when wrapping built-in types.

Python 3.13 and the classmethod revert: Python 3.9 fixed a longstanding issue where @classmethod did not invoke the descriptor protocol on the callable it wrapped. The fix enabled stacking decorators on top of @classmethod correctly. In Python 3.13, that fix was reverted because substantial third-party code had come to rely on the pre-3.9 semantics, and the wrapt documentation explicitly notes that Python 3.13 restored the original pre-3.9 descriptor behavior. The original recommendation therefore still stands: always apply @wrapt.decorator outside of @classmethod, never inside. Code that relied on the 3.9–3.12 behavior will silently misbehave on 3.13.

Python 3.13 Regression

The descriptor protocol fix for @classmethod from Python 3.9 was reverted in Python 3.13. Always place your decorator outside (above) @classmethod, not inside it. This is the only arrangement that works correctly across all Python versions from 3.x through 3.13+.

Python Pop Quiz
After applying @functools.wraps(func) to your decorator, a teammate calls inspect.getsource(decorated_fn) and gets the wrapper closure's source instead of the original function's. Why?

wrapt 2.x: What Changed

The wrapt library reached its 2.x series in October 2025 with the release of version 2.0.0. The current release as of this writing is 2.1.2 (March 2026). According to the author's own release announcement, the major version bump was driven primarily by the removal of all Python 2 legacy code, along with subtle internal changes to ObjectProxy behavior that — while believed to be backward-compatible — warranted extra caution under a new major version number rather than a patch release.

One change is critical to know before upgrading from wrapt 1.x to 2.x in an existing codebase: the object proxy classes now raise WrapperNotInitializedError — a custom exception that inherits from both ValueError and AttributeError — rather than a plain ValueError when a proxy is accessed before initialization. The dual inheritance was added specifically to address PyCharm's behavior: PyCharm's debugger accesses __wrapped__ before __init__ is called and expects AttributeError, not ValueError. Code that catches ValueError from proxy operations will still catch the new exception, but code that catches only AttributeError will now also catch it where it previously would not have.

On the performance side: wrapt ships a C extension that handles the proxy and binding overhead. When the extension is compiled (which it will be on any standard CPython installation via pip), a wrapt-wrapped call adds negligible overhead compared to the pure Python fallback. The library automatically falls back to pure Python on platforms without a compiler. In benchmark-sensitive hot paths, it is worth verifying which implementation your environment uses; wrapt.__version__ reports the library version and the module's repr will indicate whether the C extension or the Python fallback is active.

Why inspect.getsource() Still Fails After functools.wraps

functools.wraps fixes help() output and inspect.signature(), but it does not fix inspect.getsource(). The getsource() function locates source code by reading the file path and line number from the function's __code__ object. Because functools.wraps does not copy __code__ — it cannot, since that is a compiled code object intrinsic to the wrapper — inspect.getsource(decorated_func) returns the wrapper's source code, not the original function's, regardless of Python version when functools.wraps is used.

When wrapt is used instead, inspect.getsource() returns what you would expect: the decorated function's source, including the decorator line above it. This is because wrapt creates a transparent object proxy that routes attribute access — including the __code__ lookup path — to the original function rather than to a wrapper closure. The Python documentation for inspect.getsource() notes that source retrieval depends on the function's file reference, which functools.wraps intentionally does not alter.

In this example, inspect.getsource() will return the wrapper closure's source code — the audit_log body — regardless of Python version, because functools.wraps does not alter __code__. If you need getsource() to return the original function's source, use the wrapt library instead.

import functools, inspect

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."""
    return principal * (1 + rate / n) ** (n * years)

# getsource() returns the wrapper's source on all Python versions when functools.wraps is used
# because functools.wraps does not copy __code__
print(inspect.getsource(calculate_compound_interest))
# Returns the audit_log wrapper closure body, not the original function

# To get the original function's source, access it via __wrapped__:
print(inspect.getsource(calculate_compound_interest.__wrapped__))
# Returns the calculate_compound_interest source

This distinction matters in environments that display source code on hover — IDEs like PyCharm and VS Code that call inspect.getsource() for their documentation popups will show wrapper boilerplate instead of the function body when functools.wraps is the only tool applied. The wrapt library resolves this on all Python versions by making the proxy transparent at the __code__ level.

The Downstream Tooling Cost of Missing functools.wraps

When functools.wraps is absent, the damage does not stop at the interactive help() call. Every tool in the Python ecosystem that reads function metadata is affected, and the failure modes vary enough that each deserves attention.

Sphinx and pdoc: Documentation Generation

Sphinx, the standard Python documentation generator, calls inspect.signature() and reads __doc__ when generating API documentation. When a decorator is missing functools.wraps, every decorated function in the public API produces a documentation page that describes wrapper(*args, **kwargs) with no docstring. The resulting HTML documentation is functionally useless for those functions. pdoc3 and pdoc have the same dependency: they both read __doc__ and call inspect.signature(). There is no flag in Sphinx, pdoc, or any mainstream documentation generator to compensate for missing metadata — the metadata must be correct at the source level.

mypy and pyright: Static Type Checking

Static type checkers handle functools.wraps as a special case. mypy recognizes the @functools.wraps decorator and propagates the original function's type signature to the wrapper. If functools.wraps is absent, mypy infers the wrapper's type as (*args: Any, **kwargs: Any) -> Any, which eliminates all type safety for callers of the decorated function. Pyright has the same behavior: it follows __wrapped__ to retrieve the original signature for type inference purposes. Both tools do this by special-casing functools.wraps, not by following __wrapped__ at runtime — which means a decorator that sets __wrapped__ manually without using functools.wraps may not receive the same treatment from static analyzers. Using the standard library function is the safest choice for tool compatibility.

IDE Autocompletion and Hover Documentation

VS Code (via Pylance) and PyCharm both use inspect.signature() to populate parameter hints and hover documentation. When a developer hovers over a call to a decorated function in VS Code, the tooltip reads from the same attributes that help() does. Without functools.wraps, every decorated function in the codebase shows (*args, **kwargs) in the autocomplete dropdown and an empty docstring on hover. In a codebase with heavy decorator use — authentication decorators, rate-limiting decorators, caching decorators — this effectively disables IDE assistance for the decorated surface area entirely.

Jupyter Notebooks and the REPL

In Jupyter notebooks, ? and ?? call inspect.getdoc() and inspect.getsource() respectively. A function decorated without functools.wraps returns no documentation when ? is used. This is a particularly visible failure point in data science and research contexts, where decorated functions (logging, timing, parameter validation) are common and interactive documentation is a primary workflow tool. The ?? operator additionally calls inspect.getsource(), which — as covered earlier — returns the wrapper source rather than the original function's source unless wrapt is used.

Frequently Asked Questions

Why does help() show wrapper instead of my function's name and docstring?

Python's help() function reads three attributes from a function object: __name__ for the displayed name, inspect.signature() for the parameter list, and __doc__ for the docstring. When a decorator replaces a function with an inner wrapper, those attributes belong to the wrapper, not the original function. The fix is to apply @functools.wraps(func) to the wrapper function inside the decorator.

Does functools.wraps fix the signature shown by help()?

Yes. functools.wraps adds a __wrapped__ attribute pointing to the original function. Python's inspect.signature() follows __wrapped__ to retrieve the original parameter list. Since help() uses inspect.signature() internally, the correct signature appears in the output after applying @functools.wraps.

How do I fix help() output for a class-based decorator?

Class-based decorators cannot use the @functools.wraps decorator syntax since there is no inner function to decorate. Instead, call functools.update_wrapper(self, func) inside the __init__ method. This performs the same metadata copy operation and adds the __wrapped__ attribute.

What is the difference between functools.wraps and the wrapt library?

functools.wraps copies metadata and adds __wrapped__ so that inspect.signature() can follow it to the original signature. The third-party wrapt library goes further by creating a transparent object proxy that delegates attribute access to the original function, producing fully signature-preserving wrappers without relying on __wrapped__ at all. wrapt also handles edge cases with class methods, static methods, and descriptors that functools.wraps does not.

Can I customize which attributes functools.wraps copies?

Yes. functools.wraps accepts an assigned parameter that controls which attributes are copied. The default is WRAPPER_ASSIGNMENTS: (__module__, __name__, __qualname__, __annotations__, __type_params__, __doc__). You can extend this tuple to include additional attributes like __defaults__ or __kwdefaults__ by passing assigned=functools.WRAPPER_ASSIGNMENTS + ('__defaults__',).

Does functools.wraps fix inspect.getsource() for decorated functions?

No. functools.wraps does not fix inspect.getsource() on any Python version because it does not copy __code__, which is the compiled code object that getsource() uses to locate the source file and line number. Calling inspect.getsource() on a functools.wraps-decorated function always returns the wrapper closure's source, not the original function's. The workaround is to call inspect.getsource(func.__wrapped__) directly. For fully transparent source retrieval without manual unwrapping, use the wrapt library.

What changed in functools.wraps between Python 3.11 and Python 3.12?

Python 3.12 added __type_params__ to WRAPPER_ASSIGNMENTS. This attribute stores the type parameters of generic functions defined using PEP 695 syntax. On Python 3.11 and earlier the attribute does not exist and the copy is a no-op. There is a known edge case on Python 3.12 and later: applying functools.update_wrapper to a callable that is a built-in type can raise TypeError because type.__type_params__ is a descriptor, not a plain attribute. The fix is to exclude '__type_params__' from the assigned tuple when wrapping built-in types.

Does missing functools.wraps affect tools beyond help(), like Sphinx, mypy, or IDEs?

Yes, and the impact is broader than many developers realize. Sphinx and pdoc both call inspect.signature() and read __doc__ to generate API documentation — a missing functools.wraps produces documentation pages showing wrapper(*args, **kwargs) with no docstring. mypy and pyright special-case the @functools.wraps decorator to propagate the original function's type signature to the wrapper; without it, both infer (*args: Any, **kwargs: Any) -> Any and all type safety for callers is lost. VS Code (Pylance) and PyCharm read the same attributes to populate parameter hints and hover documentation. Jupyter's ? operator calls inspect.getdoc(). A decorator missing functools.wraps silently degrades every one of these tools at once.

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__ (from Python 3.12 onward), 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. Python 3.13 reverted the classmethod descriptor fix from 3.9. Always place decorators outside (above) @classmethod. This is the only stacking order that works consistently across all Python versions.
  6. functools.wraps does not fix inspect.getsource(). Because functools.wraps does not copy __code__, getsource() always returns the wrapper closure's source, not the original function's. Call inspect.getsource(func.__wrapped__) directly to access the original, or use wrapt for fully transparent source resolution.
  7. 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.
  8. The impact extends beyond help(). Sphinx and pdoc use __doc__ and inspect.signature() to generate API documentation. mypy and pyright special-case functools.wraps to propagate the original function's type signature. VS Code (Pylance) and PyCharm populate parameter hints and hover docs from the same attributes. Jupyter's ? operator calls inspect.getdoc(). A decorator missing functools.wraps silently degrades every one of these tools at once.

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.