Level Up Your Python With Decorators

Have you ever looked at code using mysterious @ symbols and wondered what was going on? Do words like wrappers, factory functions, and closures give you a headache? Well, my friend, let me tell you โ€“ you‘re not alone!

Decorators can sound so fancy and intimidating at first. But the truth is, they are incredibly useful in practice and easier to grasp than you think with the right examples and a friendly teacher to guide you :). In this deep dive tutorial, we‘ll gently unravel the mysteries of Python decorators together step-by-step.

I‘ll share lots of annotated code snippets so you build solid intuition on how decorators work under the hood. We‘ll cover common applications like timing functions, logging, caching โ€“ you‘ll be amazed at the capabilities unlocked with these constructs! We‘ll even peek at some gotchas to avoid.

Sound exciting? Don your thinking ๐Ÿงข โ€“ and let‘s get decorating!

Gift Wrapping Functions with Decorators

Let‘s think about gift wrapping presents during the holidays. Wrapping paper and ribbons nicely decorate gifts, almost magically enhancing them without changing what‘s inside.

Python decorators provide similar magic for functions!

These wrapper constructs allow transparently enhancing functions by nesting them inside other callable entities. For example:

import time

# Our gift wrap equivalent 
def timer(func):

    # Wrapper function
    def wrapper():
        start = time.time()

        # Wrap the given function  
        func()  

        end = time.time()
        print(f"{func.__name__} took {end-start} secs")

    return wrapper

@timer
def long_task():
    print("Running long task...")
    for x in range(1000): 
        y = x ** 20 

long_task()
# Prints "long_task took 0.22 secs" after task runs

Here timer acts as a decorator that measures execution times for any function passed into it. We decorate long_task by prefixing it with @timer. Now long_task magically gains timing powers without having to change its implementation!

This enables transparently extending functions in a reusable way. ๐Ÿ˜Š Pretty nifty right?

But before seeing more decorator magic tricks, let‘s formally decode what they are under the hoodโ€ฆ

Demystifying the Inner Workings

The @decorator syntax is just syntactic sugar for wrapping functions behind the scenes. Here‘s what really happens under the hood:

Without decorators:

def long_task():
   print("Running long task...")
    # Code for the actual task

long_task = timer(long_task) 

With decorators:

@timer
def long_task():
   print("Running long task...")
   # Code for actual task 

So @timer automatically applies timer() as a wrapper over long_task to augment its behavior.

In general, here is the pattern behind decorators:

# Decorator function 
def decorator(func):

    # Wrapper extends func
    def wrapper(*args, **kwargs):  
        # Extensions like pre/post processing 
        func(*args, **kwargs)

    return wrapper 

# Apply decorator by pre-pending @  
@decorator
def func():
    pass

When Python sees @decorator, it basically calls:

func = decorator(func)

So func gets replaced by the decorated function returned by the decorator!

With that demystified, let‘s unleash some real world Python decorator magic with practical examples. Onwards my friend! ๐Ÿš€

Python code screenshot Real-World Use Cases

While you can craft custom decorators like shown above, Python ships with helpful ones in the standard library too. Let‘s dive into examples.

Timing Functions

Let‘s revive our trusty @timer decorator from earlier for benchmarking code:

from timeit import default_timer 

def timer(func):
    def wrapper(*args, **kwargs):
        start = default_timer()

        result = func(*args, **kwargs)

        end = default_timer()
        print(f"{func.__name__} took {end - start}")
        return result
    return wrapper

@timer
def random_sleep(sleeptime):
    import random
    import time
    time.sleep(random.random()*sleeptime)

random_sleep(10) 
# Prints "random_sleep took 7.23 secs" 

The builtin default_timer() quantifies performance overhead.

You can also use cProfiler for low-level insights:

import cProfile

cProfile.run(‘random_sleep(10)‘)
# Prints detailed stats like function calls, time per call 
# and lines consuming highest time

Logging Function Arguments + Results

Automatically log params to audit flows or debug issues:

from functools import wraps
import logging

# Set logging level 
logging.basicConfig(level=logging.INFO)  

def log_func(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        logging.info(
            f"Running {func.__name__} with {args} and {kwargs}")
        result = func(*args, **kwargs)
        logging.info(f"{func.__name__} returned {result}")
        return result
    return wrapper

@log_func
def add(x, y):
    return x + y

print(add(10, 20)) 
# Logs the arguments, function name and return value  

Note @wraps preserves metadata like docstrings when decorating.

Type Checking Parameters

Confirm param datatypes at runtime:

from inspect import signature
from functools import wraps

def type_check(func):
    sig = signature(func)

    @wraps(func)
    def wrapper(*args, **kwargs):
        bound = sig.bind(*args, **kwargs)
        for name, val in bound.arguments.items():
            if name == ‘self‘: 
                continue

            ann = sig.parameters[name].annotation
            if ann is inspect._empty:
                continue

            if not isinstance(val, ann):
                raise TypeError(
                    f"{name} should be {ann}, not {type(val)}") 

        return func(*args, **kwargs)

    return wrapper

@type_check
def process(inputs: list):
    return inputs

process([10, 20]) # Runs OK  
process("foo") # TypeError!

Memoization & Caching

Remember prior expensive calculations via caching:

from functools import lru_cache

@lru_cache(maxsize=None)  
def fibonacci(n):
    if n < 2: 
        return n
    return fibonacci(n-2) + fibonacci(n-1)

print([fibonacci(n) for n in range(10)]) 
# Cached values reused instead of recomputing

Policies like LRU manage cache sizes.

Rate Limiting

Throttling high frequency calls:

from functools import wraps
import time

def rate_limited(max_per_second):
    min_interval = 1 / float(max_per_second)
    def decorator(func):
        last_time = [0.0] 
        @wraps(func)
        def wrapper(*args, **kwargs):
            elapsed = time.perf_counter() - last_time[0]
            left_to_wait = min_interval - elapsed
            if left_to_wait > 0:
                time.sleep(left_to_wait) 
                last_time[0] = time.perf_counter() 
            return func(*args, **kwargs)
        return wrapper
    return decorator

@rate_limited(5) # 5 calls per second
def print_num(num):
    print(num)

This builds in pacing.

Authorization

Enable role based access control:

import functools

USER_ROLES = [‘guest‘, ‘admin‘]

def role_required(role):  
    def decorator(f):
        @functools.wraps(f)
        def wrapper(*args, **kwargs):
            if role not in USER_ROLES:
              raise ValueError(f"Invalid role {role}")  
            if role != ‘admin‘:
                raise UnauthorizedError(f"Role {role} cannot access this") 
            return f(*args, **kwargs)
        return wrapper
    return decorator

@role_required(role=‘admin‘)
def admin_dashboard():
    print("Welcome to admin dashboard!")

admin_dashboard() # OK! 

@role_required(role=‘guest‘)  
def home_page():
    return "<p>Welcome home!</p>"

home_page() # UnauthorizedError!

This separates access policies from core logic.

There are endless opportunities for usefulness like input validation, debugging capabilities, app connectivity etc!

Code screenshot Advanced Tactics

Let‘s level up decorator mastery with some advanced tactics:

Passing Arguments to Decorators

Customize wrappers without editing their code:

from functools import wraps 

def ensure_permission(perm):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            if check_permissions(perm):
                return func(*args, **kwargs)
        return wrapper  
    return decorator

@ensure_permission("admin")  
def delete_user():
   print(‘Deleting user...‘)  

Now you can reuse ensure_permission flexibly without hardcoding roles!

Nesting Decorators

Apply multiple custom wrappers:

def header(text):
    def wrap(func):
        def wrapped():  
            print(f"\n====== {text} ======")
            return func()
        return wrapped
    return wrap

def timer(func):
    # timing wrapper code

@timer 
@header("Start Process")
def process_data():
   print(‘Processing...‘, flush=True)

process_data()  
# Prints heading then timing data

This enables nice capability composability! ๐Ÿ˜Ž

Classes Too Please!

Let‘s decorate methods in classes:

def debug(method):
    def wrapper(*args, **kwargs):
        print(f"Calling {method.__name__}")  
        return method(*args, **kwargs)
    return wrapper

class Calculator:
    @debug 
    def add(self, x, y):
        return x + y

calc = Calculator()  
calc.add(10, 20) # Prints method name  

So decorators work on free functions and class instance methods.

But use caution around __init__ methods during initialization.

![Warning sign](https://pluralsight.imgix.net/guides/e49b2dfaโ€“ emphasizes the importance of something-99a9-4562-aee8-ffd7a6f5b8f4_1638103329820.html) Potential Pitfalls

With great power comes great responsibility! ๐Ÿ’ช Before you go overboard on decorators:

โŒ Performance Overhead: Too many wrappers slow functions down. Profile code to spot impacts!

โ›”๏ธ Leaky Wrappings: Decorators can unintentionally mutate mutable arguments passed to functions.

๐ŸŒก๏ธ Hidden Metadata: Applying wrappers hides useful attributes like docstrings, names etc of wrapped functions. Rely on functools.wraps where needed.

These issues can sneak up on you! So tread carefully and debug issues with care.

When to Reach for That Decorator?

Here is a handy decision tree on when to use Python decorators vs alternatives like inheritance and composition:

 Do you need...
    โ”‚
    โ”œโ”€โ”€โ”€โ”€ State and polymorphism? 
    โ”‚         โ”‚
    โ”‚         โ”œ> Use inheritance
    โ”‚
    โ”œโ”€โ”€โ”€โ”€ Function composition?
    โ”‚         โ”‚ 
    โ”‚         โ”œ> Use higher order functions 
    โ”‚
    โ””โ”€โ”€โ”€โ”€ To augment functions transparently?  
            โ”‚
            โ”œ> Use Python decorators!!!

Decorators shine best when wanting to transparently tack on cross-cutting capabilities like logging, timing, validation etc. across disparate functions.

Hope you now have sweet intuition on sprucing up functions with Python decorators!

Let‘s raise a toast with cake ๐Ÿฐ to your future decorator endeavours!