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.
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.
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
help()reads three sources from the function object. It gets the name from__name__, the parameter list frominspect.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.functools.wrapsis 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 thatinspect.signature()follows to retrieve the correct parameter list.- For class-based decorators, call
functools.update_wrapper(self, func)in__init__. This is the function thatfunctools.wrapscalls internally, adapted for use when there is no inner function to decorate. - For library code with edge cases, consider the
wraptlibrary. 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. - Verify decorator transparency in tests. Check that
__name__,__doc__,__wrapped__, andinspect.signature()all reflect the original function. Catching metadata loss in automated tests prevents it from reaching production, where brokenhelp()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.