Enhancing Functions: Decorators/shaare/OPgX0g
Enhancing Functions: Decorators
- A decorator is a callable that takes another function, adds behaviour before and/or after it runs, and returns a new callable.
- They solve cross‑cutting concerns such as logging, timing, permission checks, or retries without cluttering core logic.
- The magic
@decorator_namesyntax is shorthand for passing the target function to the decorator and re‑binding the original name to the returned wrapper.
Decorator Anatomy (Manual View)
- Outer decorator function accepts the target function and creates a wrapper inside it.
- The wrapper usually takes
*args, **kwargsso it can handle any signature. - Wrapper executes optional "before" code, calls the original, maybe does "after" code, and returns the original’s result.
- Returning the wrapper from the decorator completes the transformation.
Using decorators:
- Manually wrapping illustrates what
@syntax really does behind the scenes. - This approach is clear but repetitive:
@eliminates the manual reassignment step.
import time
def simple_task(sleep_duration):
time.sleep(sleep_duration)
print("Running a simple task...")
def timing_decorator(original_function):
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = original_function(*args, **kwargs)
duration = time.perf_counter() - start
print(f"{original_function.__name__} took {duration:.3f}s")
return result
return wrapper
simple_task = timing_decorator(simple_task)
simple_task(0.3)
The @ Syntax
- Placing
@decorator_namedirectly abovedef my_func():triggersmy_func = decorator_name(my_func)at definition time. - After that line is executed,
my_funcrefers to the wrapper returned by the decorator, so callers automatically get enhanced behaviour. - This keeps the decoration visible and close to the function definition, improving readability.
@timing_decorator
def another_task():
print("Running another task...")
another_task()
Configurable Decorators: Decorators with Arguments
- A basic decorator adds fixed behavior; sometimes you need to configure that behaviour (e.g. how many retries, which log level).
- You cannot pass options directly to a plain
@decorator, because that decorator receives only the target function. - Solution: call a factory that takes options and returns a decorator, then apply it with
@factory(option=value).
def timing_decorator(original_function):
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = original_function(*args, **kwargs)
duration = time.perf_counter() - start
print(f"{original_function.__name__} took {duration:.3f}s")
return result
return wrapper
The Decorator Factory Pattern
- Factory function receives configuration arguments and returns the actual decorator.
- The actual decorator still takes the target function and builds a wrapper.
- The wrapper can access both the factory’s configuration (via a closure) and the call‑time
*args / **kwargsfor the target function. - Three nested layers keep concerns separated: configuration ➜ decoration ➜ runtime.
Applying Decorators with Arguments
- Use
@factory(arg1, arg2…)above the function definition. - At definition time Python calls the factory, gets back a decorator, and applies that decorator to the function.
- Callers of the function automatically get the behaviour configured by the factory.
Example: Retry Decorator Factory
- A practical DevOps scenario: retry a flaky operation a configurable number of times.
- The factory takes
max_attempts; the wrapper loops until success or until attempts are exhausted, re‑raising the last error.
import random
def retry(max_attempts=3):
def decorator(func):
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
print(f"Attempt {attempt}/{max_attempts}")
return func(*args, **kwargs)
except Exception as e:
print(f" Error: {e}")
if attempt == max_attempts:
raise
return wrapper
return decorator
@retry(4)
def sometimes_fails():
if random.random() < 0.7:
raise RuntimeError("Flaky failure")
return "Success!"
print(f"Result: {sometimes_fails()}")
Configurable Decorators: Decorators with Arguments
- A basic decorator adds fixed behavior; sometimes you need to configure that behaviour (e.g. how many retries, which log level).
- You cannot pass options directly to a plain
@decorator, because that decorator receives only the target function. - Solution: call a factory that takes options and returns a decorator, then apply it with
@factory(option=value).
def timing_decorator(original_function):
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = original_function(*args, **kwargs)
duration = time.perf_counter() - start
print(f"{original_function.__name__} took {duration:.3f}s")
return result
return wrapper
The Decorator Factory Pattern
- Factory function receives configuration arguments and returns the actual decorator.
- The actual decorator still takes the target function and builds a wrapper.
- The wrapper can access both the factory’s configuration (via a closure) and the call‑time
*args / **kwargsfor the target function. - Three nested layers keep concerns separated: configuration ➜ decoration ➜ runtime.
Applying Decorators with Arguments
- Use
@factory(arg1, arg2…)above the function definition. - At definition time Python calls the factory, gets back a decorator, and applies that decorator to the function.
- Callers of the function automatically get the behaviour configured by the factory.
Example: Retry Decorator Factory
- A practical DevOps scenario: retry a flaky operation a configurable number of times.
- The factory takes
max_attempts; the wrapper loops until success or until attempts are exhausted, re‑raising the last error.
import random
def retry(max_attempts=3):
def decorator(func):
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
print(f"Attempt {attempt}/{max_attempts}")
return func(*args, **kwargs)
except Exception as e:
print(f" Error: {e}")
if attempt == max_attempts:
raise
return wrapper
return decorator
@retry(4)
def sometimes_fails():
if random.random() < 0.7:
raise RuntimeError("Flaky failure")
return "Success!"
print(f"Result: {sometimes_fails()}")
Decorators & Return Values
- A decorator’s wrapper replaces the original function, so if it forgets to return the original result the caller receives
None. - Many real‑world functions produce critical data (e.g. status strings, dictionaries, numeric results); the decorator must be transparent about that value.
- Fixing this means capturing the result of
func(*args, **kwargs)inside the wrapper and returning it unchanged.
def log_calls_broken(func):
def wrapper(*args, **kwargs):
print(f"LOG: Calling {func.__name__}")
func(*args, **kwargs)
print(f"LOG: Finished {func.__name__}")
return wrapper
@log_calls_broken
def add(x, y):
return x + y
print(f"Result seen by caller: {add(2, 3)}")
The Wrapper’s Responsibility
- The wrapper is the public face of the decorated function; it must faithfully:
- Call the original with all arguments.
- Capture its return value.
- Perform any extra behaviour (log, time, validate).
- Return the captured value so callers remain unaware of the wrapper.
- Failure to return breaks contracts and causes subtle bugs.
Capturing return values
- Capturing is a one‑liner:
value = func(*args, **kwargs). - After post‑call logic,
return valuepreserves behaviour. - You can also inspect or transform
valuebefore returning if the decorator’s purpose demands it.
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"LOG: Calling {func.__name__}")
value = func(*args, **kwargs)
print(f"LOG: Finished {func.__name__}")
return value
return wrapper
@log_calls
def multiply(a, b):
return a * b
print(f"Result seen by caller: {multiply(2, 3)}")
Handling Exceptions in Decorators
- Wrappers often log exceptions for observability but should re‑raise them so callers can still handle or see errors.
- Use
try ... except ... raisearound the call; log inside theexcept, then re‑raise without arguments to preserve traceback. - A decorator that swallows exceptions changes program semantics unless that is its explicit purpose (e.g. retry).
def log_and_reraise(func):
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except Exception as err:
print(f"[ERROR] {func.__name__} raised {err.__class__.__name__}")
raise
return wrapper
@log_and_reraise
def fail():
raise ValueError("simulated problem")
fail()
functools.wraps
- A decorator replaces the original function object with its wrapper, so introspection tools see the wrapper’s metadata instead of the original’s.
- Attributes such as
__name__,__doc__,__module__, and type‑hint annotations are lost or altered. - This confuses debuggers, documentation generators, and anyone relying on
help(),inspect, or error traces that reference the function name. - Python’s
functoolsmodule supplies@wraps(original_func); apply it inside your decorator to the wrapper. @wrapscopies key metadata from the original function onto the wrapper, so the decorated function still looks like the original externally.
def broken_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@broken_decorator
def add(a, b):
"""Return the sum of two numbers."""
return a + b
print("Introspection without @wraps:")
print(f" __name__: {add.__name__}")
print(f" __doc__: {add.__doc__}")
from functools import wraps
def correct_decorator(func):
@wraps(func) # Best practice: Always use it!
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@correct_decorator
def multiply(a, b):
"""Return the product of two numbers."""
return a * b
print("Introspection with @wraps:")
print(f" __name__: {multiply.__name__}")
print(f" __doc__: {multiply.__doc__}")
Stacking Decorators: Applying Multiple Layers
- Python lets you attach more than one decorator to a single function by writing multiple
@decoratorlines above thedef. - Each decorator contributes a distinct slice of behaviour (logging, timing, caching, auth checks) keeping the core function clean.
Application vs. Execution Order
- Decoration happens bottom‑up when the function is defined:
- Decorator nearest the
defwraps the original first. - Each line above wraps the result of the previous decoration.
- Decorator nearest the
- Execution happens top‑down (outside‑in) when the decorated function is called: the outermost wrapper runs first, then calls the inner wrapper, and so on until the original function runs.
Order Matters
- Swapping decorator order changes both side‑effects and final result if wrappers transform the return value.
from functools import wraps
def decorator_A(func):
@wraps(func)
def wrapper(*args, **kwargs):
print("A before")
result = func(*args, **kwargs)
print("A after")
return result
return wrapper
def decorator_B(func):
@wraps(func)
def wrapper(*args, **kwargs):
print("B before")
result = func(*args, **kwargs)
print("B after")
return result
return wrapper
@decorator_A
@decorator_B
def foo():
print(" >>> inside function foo")
@decorator_B
@decorator_A
def bar():
print(" >>> inside function bar")
foo()
print("----")
bar()
(97)