Python Decorators, @classmethod & @property Explained. #23
Video: Python Decorators, @classmethod & @property Explained. #23 by Taught by Celeste AI - AI Coding Coach
Python Decorators: @wraps, @classmethod, parameterized decorators
A decorator is a function that takes a function and returns a (usually wrapping) function.
@decoabove adefis shorthand forf = deco(f). Use them for cross-cutting concerns: timing, logging, caching, auth, retries.
You've already seen @property, @classmethod, @staticmethod, @abstractmethod. Today: writing your own.
The minimal decorator
def shout(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result.upper()
return wrapper
@shout
def greet(name):
return f"hello, {name}"
print(greet("Alice")) # HELLO, ALICE
The @shout syntax is shorthand for:
def greet(name):
return f"hello, {name}"
greet = shout(greet)
shout takes a function, returns a new function (wrapper). The new function calls the original and post-processes the result.
What's happening
A decorator is just:
def decorator(func):
def wrapper(*args, **kwargs):
# before
result = func(*args, **kwargs)
# after
return result
return wrapper
- The outer function takes
func(the function being decorated). - The inner function
wrapperaccepts whatever arguments the original takes —*args, **kwargsis the universal form. - Inside, you call
func(*args, **kwargs)and do whatever wrapping you want. - Return
wrapper.
A timer decorator
import time
def timer(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
elapsed = time.time() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@timer
def slow_sum(n):
return sum(range(n))
slow_sum(1_000_000)
# slow_sum took 0.0312s
The decorated function works exactly like the original — same return value, same arguments — but with timing instrumentation.
This is the decorator pattern: cross-cutting behavior added without modifying the original function.
@wraps preserves metadata
Without @wraps, the decorated function loses its name and docstring:
def shout(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs).upper()
return wrapper
@shout
def greet(name):
"""Say hi."""
return f"hello, {name}"
print(greet.__name__) # "wrapper" — confusing!
print(greet.__doc__) # None
Fix it with functools.wraps:
from functools import wraps
def shout(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs).upper()
return wrapper
@shout
def greet(name):
"""Say hi."""
return f"hello, {name}"
print(greet.__name__) # "greet"
print(greet.__doc__) # "Say hi."
@wraps(func) copies func's __name__, __doc__, __module__, etc. onto wrapper. Always use it when writing decorators.
Decorators with arguments
def repeat(times):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(3)
def say_hello():
print("Hello!")
say_hello()
# Hello!
# Hello!
# Hello!
Three layers of nesting:
repeat(times)— factory; takes the decorator's argument.decorator(func)— the actual decorator; takes the function.wrapper(*args, **kwargs)— the replacement; takes the function's arguments.
@repeat(3) is repeat(3) (which returns decorator), then decorator(say_hello) (which returns wrapper).
A logging decorator
def log_calls(func):
@wraps(func)
def wrapper(*args, **kwargs):
arg_str = ", ".join([repr(a) for a in args] + [f"{k}={v!r}" for k, v in kwargs.items()])
print(f"-> {func.__name__}({arg_str})")
result = func(*args, **kwargs)
print(f"<- {func.__name__} returned {result!r}")
return result
return wrapper
@log_calls
def add(a, b):
return a + b
add(2, 3)
# -> add(2, 3)
# <- add returned 5
Logging at function boundaries is a classic decorator use.
Caching with @lru_cache
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
print(fib(100)) # instant — without lru_cache, this would take forever
@lru_cache memoizes the function — same args, same return, no recomputation. Built into the standard library; one of the most useful decorators in Python.
Multiple decorators
@timer
@log_calls
def slow_sum(n):
return sum(range(n))
Stack reads bottom-up:
slow_sum = log_calls(slow_sum)— wraps with logging.slow_sum = timer(log_calls(slow_sum))— wraps that with timing.
So calling slow_sum(...):
- timer's wrapper starts timing.
- log_calls's wrapper logs the call.
- The original runs.
- log_calls logs the return.
- timer prints elapsed.
Order matters. Closer-to-def runs first.
@classmethod and @staticmethod (revisited)
class DateProfile:
count = 0
def __init__(self, name, birth_year):
self.name = name
self.birth_year = birth_year
DateProfile.count += 1
@classmethod
def from_age(cls, name, age):
return cls(name, date.today().year - age)
@staticmethod
def is_valid_year(year):
return 1900 <= year <= date.today().year
@property
def age(self):
return date.today().year - self.birth_year
p = DateProfile.from_age("Bob", 25)
print(p.age)
print(DateProfile.is_valid_year(2000))
@classmethod — receives cls. Use for alternative constructors.
@staticmethod — receives nothing special. Use for namespaced utilities.
@property — turns a method into attribute access (covered in lesson 21).
These are all built-in decorators. The pattern is the same — they take a function and return a transformed object.
Class-based decorators
A decorator can also be a class with __call__:
class CountCalls:
def __init__(self, func):
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
return self.func(*args, **kwargs)
@CountCalls
def greet(name):
return f"Hello, {name}!"
greet("Alice")
greet("Bob")
print(greet.count) # 2
@CountCalls replaces greet with a CountCalls instance. The instance is callable (__call__), so greet("Alice") works as before.
Useful when the decorator needs state.
Real-world decorators
You'll see decorators everywhere:
# Flask
@app.route("/")
def home():
return "Hi"
# pytest
@pytest.fixture
def client():
...
@pytest.mark.skip
def test_x():
...
# Click CLI
@click.command()
@click.argument("name")
def hello(name):
click.echo(f"Hello, {name}!")
# Standard library
@functools.cache
@functools.lru_cache(maxsize=128)
@dataclass
@total_ordering
Frameworks use decorators to register handlers without you editing a registry. The decoration is the registration.
Common stumbles
Forgetting @wraps. func.__name__ becomes "wrapper". Always use @wraps(func).
Forgetting *args, **kwargs. Hardcoded parameter list breaks when applied to functions with different signatures.
Forgetting return. wrapper doesn't return the result → all your decorated functions return None.
Decorators with arguments without the extra layer. def deco(arg, func): ... won't work — needs three layers (factory → decorator → wrapper).
Side effects at decoration time. Code outside wrapper runs once when the function is decorated, not per call. If you need per-call work, put it in wrapper.
Stacking order confusion. @a @b @c — c is applied first (closest to def), then b, then a. Reads bottom-up.
What's next
Lesson 24: iterators and generators. __iter__, __next__, yield, generator functions, generator expressions.
Recap
A decorator wraps a function to add behavior — timing, logging, caching, validation. Pattern: outer takes func, inner takes *args, **kwargs, returns the original's result (possibly modified). Always @wraps(func) to preserve metadata. For arguments, add another layer: factory → decorator → wrapper. Use functools.lru_cache for memoization. Stack decorators bottom-up.
Next lesson: iterators and generators.