Day 17: Decorators — Modifying Functions Elegantly

🐍 Day 17: Decorators — Modifying Functions Elegantly


1. Learning Objectives

By the end of Day 17, you will be able to:

  • Explain what decorators are and why they're used
  • Write a simple decorator using the @ syntax
  • Understand how functions are first-class objects in Python
  • Apply multiple decorators to a single function
  • Create decorators that accept arguments
  • Recognize and use built-in decorators: @staticmethod, @classmethod, @property
  • Use decorators in real-world scenarios: timing, logging, access control

2. Concept Explanation

2.1 Why Decorators? — Adding Behaviour Without Touching Code

Sometimes you need to add the same behaviour (logging, timing, authentication) to many functions. Instead of duplicating code inside every function, decorators let you wrap a function with extra logic — transparently and cleanly.

# Without decorators — repetitive
def add(a, b):
    log("add called")
    return a + b

def multiply(a, b):
    log("multiply called")
    return a * b

# With decorators — behaviour extracted
@log_function_call
def add(a, b):
    return a + b

2.2 Functions Are First-Class Objects

To understand decorators, you must understand that in Python, functions are objects — you can pass them around, assign them to variables, and even define them inside other functions.

def greet(name):
    return f"Hello, {name}"

# Assign function to a variable
say = greet
print(say("Alice"))             # Hello, Alice

# Pass function as an argument
def execute(func, value):
    return func(value)

print(execute(greet, "Bob"))    # Hello, Bob

# Define function inside a function
def outer():
    def inner():
        return "Inside!"
    return inner()

print(outer())                  # Inside!

2.3 The Basic Decorator Pattern

A decorator is a function that takes another function as an argument, adds behaviour, and returns a new function.

def my_decorator(func):
    def wrapper():
        print("Something before the function.")
        func()
        print("Something after the function.")
    return wrapper

@my_decorator
def say_hello():
    print("Hello!")

say_hello()

Output:

Something before the function.
Hello!
Something after the function.

What's happening behind the scenes:

# @my_decorator is equivalent to:
say_hello = my_decorator(say_hello)

2.4 Handling Arguments — *args and **kwargs

Real functions take arguments. A robust decorator uses *args and **kwargs to pass them through.

def logger(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__} with {args}, {kwargs}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result}")
        return result
    return wrapper

@logger
def add(a, b):
    return a + b

add(3, 5)
# Calling add with (3, 5), {}
# add returned 8

2.5 Preserving Metadata — functools.wraps

When you wrap a function with a decorator, the original function's metadata (name, docstring) is lost. Use @functools.wraps(func) inside your wrapper to preserve it.

import functools

def decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        """Wrapper docstring."""
        return func(*args, **kwargs)
    return wrapper

@decorator
def greet():
    """Original docstring."""
    pass

print(greet.__name__)    # greet (not wrapper)
print(greet.__doc__)     # Original docstring.

💡 Always use @functools.wraps in your decorators unless you specifically want to change the name.


2.6 Decorators with Arguments — The Three‑Layer Cake

When a decorator itself needs arguments (e.g., @retry(3)), you need a decorator factory — a function that returns a decorator.

def repeat(n):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(n):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)
def say_hi():
    print("Hi!")

say_hi()   # Prints "Hi!" three times

2.7 Built‑in Decorators You Already Know

DecoratorPurposeExample
@staticmethodMethod that doesn't need selfdef utils():
@classmethodMethod that receives cls instead of selfdef factory(cls):
@propertyTurns a method into an attribute with optional getter/setterdef full_name(self):

2.8 Common Mistakes

MistakeConsequence
Forgetting return wrapper in the decoratorFunction becomes None
Not using *args, **kwargsDecorated function cannot accept arguments
Missing @functools.wrapsLoses original function name, docstring
Confusing decorator with argument syntaxA decorator with arguments is a function returning a decorator

3. Code Examples

Example 1: Timer Decorator

import time
import functools

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

@timer
def slow_function():
    time.sleep(0.1)

slow_function()  # slow_function took 0.1003s

Example 2: Retry Decorator

import functools

def retry(max_attempts=3):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_exception = None
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    last_exception = e
                    print(f"Attempt {attempt} failed: {e}")
            raise last_exception
        return wrapper
    return decorator

@retry(3)
def unstable():
    import random
    if random.random() < 0.7:
        raise ValueError("Bad luck!")
    return "Success"

Example 3: Access Control Decorator

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

@require_auth
def view_dashboard(user):
    return f"Welcome, {user['name']}!"

admin = {"name": "Alice", "authenticated": True}
guest = {"name": "Bob", "authenticated": False}

print(view_dashboard(admin))  # Welcome, Alice!
# view_dashboard(guest)       # PermissionError

4. Hands-On Exercises

Exercise 1: Logger Decorator

Write a decorator @log_it that prints "Calling function_name" before the function runs and "Finished function_name" after it runs. Preserve metadata.

Exercise 2: Upper‑Output Decorator

Write a decorator @upper_return that takes the string returned by the decorated function and converts it to uppercase. Test with a function that returns "hello world".

Exercise 3: Timer with Threshold

Write a decorator @timer(threshold) that prints a warning only if the decorated function takes longer than threshold seconds.

Exercise 4: Memoization Cache

Write a decorator @cache_result that stores function results in a dictionary so repeated calls with the same arguments return instantly (no recalculation). Test with a recursive Fibonacci.

Exercise 5: Multiple Decorators

Apply two decorators to a function: @upper_return and @log_it. Observe the order they execute.


5. Applied Challenge Task 🏗️

Decorator‑Based Web Request Simulator

Build a mini system that simulates API requests using decorators.

Requirements:

  1. Decorator @authenticate(required=True):

    • Checks if a user keyword argument has "authenticated": True.
    • If not, raises PermissionError.
    • If required=False, allows unauthenticated but logs a warning.
  2. Decorator @rate_limit(max_calls=5):

    • Keeps a counter per function.
    • If the function is called more than max_calls times, raises RuntimeError.
  3. Decorator @log_request:

    • Logs the function name, arguments, result, and timestamp to a file requests.log.
  4. Simulate endpoints:

    @authenticate(required=True)
    @rate_limit(max_calls=3)
    @log_request
    def get_user_data(user):
        return {"id": 1, "name": user.get("name")}
    
  5. Test the system with authenticated and unauthenticated users, exceeding rate limits, and verify the log file.

Stretch goals:

  • Combine all three into a single composite decorator @api_endpoint
  • Add a @cache_response decorator that caches responses based on arguments
  • Unit test your decorators (preview of Day 22)

6. Brief Review Summary

ConceptKey Points
First‑class functionsFunctions are objects; can be passed and returned
DecoratorFunction taking a function, returning a new one
@decoratorSyntactic sugar for func = decorator(func)
*args, **kwargsPass through arbitrary arguments
functools.wrapsPreserves original function metadata
Decorator with argumentsTriple‑layer: decorator factory returns a decorator
Built‑ins@staticmethod, @classmethod, @property

7. Preview of Next Topic — Day 18

Tomorrow we'll complete Context Managers and dive deeper into the with statement:

  • Writing context managers using __enter__ and __exit__
  • The contextlib module and @contextmanager decorator
  • Managing resources like files, database connections, and locks
  • Creating your own context managers for custom setup/teardown logic

🎯 Your Action Items for Day 17:

  1. ✅ Complete all 5 exercises
  2. ✅ Build the Decorator‑Based Web Request Simulator
  3. ✅ Experiment with ordering of multiple decorators
  4. ✅ Compare @property with regular decorator patterns

Comments

Popular posts from this blog

Day 1: Welcome to Python — Your Journey Begins

Python: Your Gateway to Coding Adventures

Day 11: List Comprehensions & Lambda Functions

Earn From the Comfort of Your Home

Build the skills to work comfortably from home, on your own terms.

Get Started Today