Skip to Content
Course content

188: Preserving Metadata with functools.wraps

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I was digging through some legacy code yesterday and noticed something weird. We had a set of functions decorated with a performance timer, but when the automated documentation generator ran, every single one of those functions was listed as being named "wrapper" and had no description. It was a mess.

If you've been writing decorators, you've probably noticed that we're essentially swapping one function for another. Let's recreate that scenario so I can show you exactly where the wheels fall off.

The Identity Crisis

Let's say I want to time how long a specific function takes to run. I'll write a quick decorator for that. I'll apply it to a function that simulates a slow API call—something with a proper docstring, because in a real codebase, that's how your teammates know what the function actually does.

import time

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        end = time.perf_counter()
        print(f"Executed {func.__name__} in {end - start:.4f}s")
        return result
    return wrapper

@timer
def fetch_user_profile(user_id):
    """Fetches a user profile from the remote database."""
    time.sleep(1.5) 
    return {"id": user_id, "name": "Jane Doe"}

# Let's call it once to make sure it works
fetch_user_profile(123)

On the surface, this works perfectly. The timing is accurate, and the return value is correct. But here is where things get strange. If I try to inspect the function—which is exactly what IDEs like PyCharm or VS Code do when you hover over a function call—this is what happens:

print(fetch_user_profile.__name__)
# Output: wrapper

print(fetch_user_profile.__doc__)
# Output: None

Wait, what? I named the function fetch_user_profile, and I wrote a clear docstring. But Python is telling me the name is "wrapper" and the documentation is gone. This is because @timer literally replaced my original function with the wrapper function defined inside the decorator. The original metadata was tossed in the trash.

Why This Actually Breaks Things

You might think, "Who cares? The code still runs." But in a professional environment, this is a nightmare. If you use a tool like Sphinx to generate your API docs, your entire manual will just say "wrapper" for every decorated function. Even worse, if you're using a debugging tool or a logging framework that relies on __name__ to track where an error occurred, your logs will just be a sea of "wrapper" errors, leaving you with no idea which function actually crashed.

Restoring the Paper Trail

Python's standard library has a built-in fix for this in the functools module. It's a decorator for your decorator called wraps. It essentially copies the metadata from the original function onto the wrapper function.

Let's try that and see if it fixes our identity crisis:

import time
from functools import wraps

def timer(func):
    @wraps(func) # This is the magic line
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        end = time.perf_counter()
        print(f"Executed {func.__name__} in {end - start:.4f}s")
        return result
    return wrapper

@timer
def fetch_user_profile(user_id):
    """Fetches a user profile from the remote database."""
    time.sleep(1.5)
    return {"id": user_id, "name": "Jane Doe"}

print(fetch_user_profile.__name__)
# Output: fetch_user_profile

print(fetch_user_profile.__doc__)
# Output: Fetches a user profile from the remote database.

There we go. By adding @wraps(func), we've told Python: "Take everything that makes fetch_user_profile unique—its name, its docstring, its module—and slap it onto the wrapper." Now the function behaves like the wrapper, but it looks like the original. It's a small addition, but it's the difference between a "script" and professional, maintainable software.




📋 Practical Task

Exercise: Fixing the Metadata Leak in a Permission Checker

You are working on a web application where several functions are decorated with a @require_admin decorator to restrict access. However, the team's introspection tool is failing because all the restricted functions are reporting their name as "check_permissions".

Below is the buggy code. Your task is to modify the require_admin decorator so that it preserves the metadata of the functions it wraps.

from functools import wraps

def require_admin(func):
    # TODO: Add the necessary decorator here to preserve metadata
    def check_permissions(*args, **kwargs):
        user_role = "guest" # Simulating a guest user
        if user_role != "admin":
            print("Access Denied: Admin role required.")
            return None
        return func(*args, **kwargs)
    return check_permissions

@require_admin
def delete_database():
    """Permanently deletes all records from the database."""
    print("Database deleted!")

@require_admin
def update_system_config():
    """Updates the global system configuration settings."""
    print("Config updated!")

# Test your fix:
print(f"Function 1 name: {delete_database.__name__}") 
# Should be 'delete_database', not 'check_permissions'

print(f"Function 2 doc: {update_system_config.__doc__}") 
# Should be 'Updates the global system configuration settings.', not None
Rating
0 0

There are no comments for now.

to be the first to leave a comment.