Python Series track

Week 02 · Advanced Track

Week 2 — Decorators & Context Managers

Master decorators for cross-cutting concerns like logging, caching, and timing. Then implement reusable context managers with both the class-based and contextlib.contextmanager approaches.

Advanced Track2 min readFree lesson

Level: Advanced | Prerequisite: Week 1

Learning Goals

  • Define and apply function decorators
  • Create decorators that accept arguments
  • Implement context managers based on Class/Function
  • Understand the role of @functools.wraps

1. What is a Decorator

Takes a function as an argument and returns a new function. Adds functionality cross-cuttingly (logging, caching, authentication, etc.).

def loud(func):
    def wrapper(*args, **kwargs):
        print(f">>> {func.__name__} call")
        result = func(*args, **kwargs)
        print(f"<<< Result: {result}")
        return result
    return wrapper

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

add(3, 5)
#>>> add call
#<<< Result: 8

@loud is syntactic sugar for add = loud(add).

2. functools.wraps

Even when wrapped with a decorator, the original function's name and docstring are preserved.

from functools import wraps

def loud(func):
    @wraps(func)            #Without this, .__name__ becomes 'wrapper'
    def wrapper(*args, **kwargs):
        ...
    return wrapper

3. Decorators that Take Arguments

A function that creates decorators (triple-nested function).

def retry(times):
    def deco(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for i in range(times):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    print(f"Retry {i+1}/{times}: {e}")
            raise
        return wrapper
    return deco

@retry(3)
def fragile():
    ...

4. Context Managers

Works with the with statement. Safely acquires and releases resources.

Class-based

class FileLock:
    def __init__(self, path):
        self.path = path
    def __enter__(self):
        print(f"Lock: {self.path}")
        return self
    def __exit__(self, exc_type, exc, tb):
        print("Unlock")

with FileLock("data.txt"):
    print("In progress")

Function-based (contextmanager)

from contextlib import contextmanager
import time

@contextmanager
def timer(label):
    start = time.perf_counter()
    yield                   #Executed with block here
    print(f"{label}: {time.perf_counter() - start:.4f}s")

with timer("Task"):
    sum(range(1_000_000))

5. Practical — Memoization

def memoize(func):
    cache = {}
    @wraps(func)
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    return wrapper

@memoize
def fib(n):
    if n < 2: return n
    return fib(n-1) + fib(n-2)

fib(100)   #Fast

Key Examples (examples/)

File Content
01_basic_decorator.py Function Decorator + wraps
02_param_decorator.py Decorator that takes arguments (retry)
03_context_class.py Class-based Context Manager
04_contextmanager.py @contextmanager Function-based

⚠️ Common Mistakes

  1. Missing @wraps — Debugging and documentation are broken. Always include it.
  2. Decorator doesn't have return — Result is None.
  3. More than one yield in @contextmanager function — Only once.
  4. Ignoring exceptions in context manager — Swallow exceptions if __exit__ returns True.

❓ FAQ

Q1. When do you use decorators? A. When adding the same additional behavior (logging, authentication, caching) to multiple functions.

Q2. What is the difference between with and try-finally? A. with is a standardized try-finally. Improves readability and prevents mistakes.

Q3. What happens if you stack multiple decorators? A. The one below is applied first. @A @B def f() → f = A(B(f)).

📝 Exercises (exercises/)

  • exercise1.md — @logged function call logging
  • exercise2.md — @cached memoization
  • exercise3.md — with timer("...") context manager

Next Week

Week 3. Deep Dive into Iterators & Generators

Example files

The runnable files used in this lesson. Copy them into your own project and experiment.

examples/01_basic_decorator.pyPython
from functools import wraps

def loud(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f">>> Calling {func.__name__} args={args}")
        result = func(*args, **kwargs)
        print(f"<<< Result: {result}")
        return result
    return wrapper

@loud
def add(a, b):
    """Sum of two numbers"""
    return a + b

add(3, 5)
print(add.__name__, add.__doc__)   #Normal operation
examples/02_param_decorator.pyPython
import random
from functools import wraps

def retry(times):
    def deco(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for i in range(times):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    print(f"Retry {i+1}/{times}: {e}")
            raise RuntimeError(f"{times} times failure")
        return wrapper
    return deco

@retry(3)
def fragile():
    if random.random() < 0.7:
        raise ValueError("Temporary error")
    return "Success"

print(fragile())
examples/03_context_class.pyPython
class FileLock:
    def __init__(self, path):
        self.path = path

    def __enter__(self):
        print(f"Acquiring lock: {self.path}")
        return self

    def __exit__(self, exc_type, exc, tb):
        print(f"Releasing lock: {self.path}")
        if exc:
            print(f"Exception occurred: {exc}")
        return False    #If False, propagate the exception as is

with FileLock("data.txt"):
    print("In progress")

print("---")

try:
    with FileLock("data.txt"):
        raise ValueError("Intentional error")
except ValueError as e:
    print("Caught outside:", e)
examples/04_contextmanager.pyPython
from contextlib import contextmanager
import time

@contextmanager
def timer(label):
    start = time.perf_counter()
    try:
        yield               #with block execution
    finally:
        elapsed = time.perf_counter() - start
        print(f"{label}: {elapsed:.4f}s")

with timer("Total"):
    total = sum(range(1_000_000))

with timer("Alignment"):
    sorted(range(100_000), reverse=True)

Assignment

Goal

Decorator that logs function calls to a file.

Requirements

  • Records function name, arguments, and result in one line to app.log upon call
  • Format: 2026-05-09 14:30 add(3, 5) -> 8

Example of use

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

add(3, 5)
#Recorded in app.log

Task 2. @cached Memoization

Goal

Caches function results to speed up repeated calls with the same arguments.

Requirements

  • dict cache using arguments (tuple) as the key
  • Use @wraps
  • cache_info() — Hit/Miss count (bonus)

Example of use

@cached
def slow_square(n):
    time.sleep(0.5)
    return n * n

slow_square(5)   #0.5 seconds
slow_square(5)   #Instant (Cache Hit)

Task 3. with timer("...") Context Manager

Goal

Context manager that measures execution time.

Requirements

  • Both @contextmanager or class-based are OK
  • Prints time even if an exception occurs (try/finally)
  • Output format: [timer] Task name: 0.1234s

Example of use

with timer("Alignment"):
    sorted(big_list)

with timer("API Call"):
    requests.get(...)
Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

solution1.pyPython
from functools import wraps
from datetime import datetime
from pathlib import Path

LOG = Path("app.log")

def logged(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        ts = datetime.now().strftime("%Y-%m-%d %H:%M")
        args_str = ", ".join(repr(a) for a in args)
        kw_str = ", ".join(f"{k}={v!r}" for k, v in kwargs.items())
        all_args = ", ".join(filter(None, [args_str, kw_str]))
        with LOG.open("a", encoding="utf-8") as f:
            f.write(f"{ts} {func.__name__}({all_args}) -> {result!r}\n")
        return result
    return wrapper

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

@logged
def greet(name, greeting="Hello"):
    return f"{greeting}, {name}"

print(add(3, 5))
print(greet("Hong Gil-dong"))
print(greet("Alice", greeting="Hello"))
print("Log:")
print(LOG.read_text(encoding="utf-8"))

LOG.unlink()
solution2.pyPython
import time
from functools import wraps

def cached(func):
    cache = {}
    hits = misses = 0

    @wraps(func)
    def wrapper(*args):
        nonlocal hits, misses
        if args in cache:
            hits += 1
            return cache[args]
        misses += 1
        cache[args] = func(*args)
        return cache[args]

    def info():
        return {"hits": hits, "misses": misses, "size": len(cache)}

    wrapper.cache_info = info
    return wrapper

@cached
def slow_square(n):
    time.sleep(0.1)
    return n * n

start = time.perf_counter()
slow_square(5)
slow_square(5)
slow_square(5)
slow_square(7)
elapsed = time.perf_counter() - start
print(f"Total time: {elapsed:.3f}s (about ~0.4s if no cache)")
print(slow_square.cache_info())
solution3.pyPython
from contextlib import contextmanager
import time

@contextmanager
def timer(label):
    start = time.perf_counter()
    try:
        yield
    finally:
        elapsed = time.perf_counter() - start
        print(f"[timer] {label}: {elapsed:.4f}s")

with timer("Alignment"):
    sorted(range(500_000), reverse=True)

#Print time even if an exception occurs
try:
    with timer("Task that will fail"):
        time.sleep(0.05)
        raise RuntimeError("Failure")
except RuntimeError as e:
    print("Caught outside:", e)

Learn this lesson with a mentor

Work through the Python Series track on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now