Python / Maintainable systems / Lesson 15
Decorators and resource contexts
Wrap behavior without losing function contracts, and express resource acquisition and cleanup with reliable context boundaries.
On this page
Lesson goals
- Functions are ordinary values
- Compose lifetimes deliberately
Functions are ordinary values
A function can be passed to another function, stored, or returned. A decorator accepts a callable and returns a replacement, commonly a wrapper that adds behavior before or after forwarding the call. The @decorator syntax applies that transformation when the definition executes. A closure allows the wrapper to retain a reference to the original function and any configuration. Keep state lifetime clear: a cache or counter stored in the closure can persist across many calls and may need bounds or synchronization. A decorator should preserve the documented result and failure behavior unless its purpose explicitly changes that contract.
Preserve metadata and argument behavior
A transparent wrapper commonly accepts *args and **kwargs and forwards them to the wrapped callable. functools.wraps copies useful metadata and records __wrapped__, supporting inspection and documentation. It does not automatically preserve every semantic property, nor make a synchronous wrapper appropriate for an async function or generator. Retrying changes timing and can repeat side effects, so use it only when operations and failure policy justify it. Logging must avoid revealing sensitive arguments. Cache keys require stable hashable values, and cached results may become stale or shared mutable objects. Cross-cutting behavior is convenient precisely because it affects many calls, so its contract needs careful review.
A small transparent wrapper
The first example adds a deterministic call notification, forwards arguments and return value, and preserves the function’s name. Its purpose is observational; it does not swallow errors or alter the result. The print is appropriate for a teaching demonstration, while an application would typically use a configured logger with safe fields. Notice that the wrapper returns the original result rather than merely calling the function. Forgetting that return changes every successful call into None and can cause a distant TypeError. Testing the returned value as well as metadata catches both contract and inspection regressions.
from functools import wraps
def announce(function):
@wraps(function)
def wrapper(*args, **kwargs):
print("calling", function.__name__)
return function(*args, **kwargs)
return wrapper
@announce
def double(value):
return value * 2
print(double(6))
print(double.__name__)
calling double
12
double
The notification precedes the original call. Returning its result preserves twelve, and wraps retains the public name double.
Context managers own a lifetime
A context manager defines entry and exit around a with block. A class can implement __enter__ and __exit__, or contextlib.contextmanager can adapt a generator that yields exactly once. Setup occurs before yield, the with body runs while suspended, and cleanup belongs in finally after yield. Exceptions from the body are reintroduced at the yield point; catching one and failing to re-raise suppresses it. The second example manages an in-memory text buffer and proves closure afterward without opening external files. Resource release and business rollback are different promises: closing a file does not undo bytes already written.
from contextlib import contextmanager
from io import StringIO
@contextmanager
def text_buffer():
buffer = StringIO()
try:
yield buffer
finally:
buffer.close()
with text_buffer() as buffer:
buffer.write("ready")
print(buffer.getvalue())
print(buffer.closed)
ready
True
The body can use the open buffer. Exiting resumes the generator into finally, and the retained reference confirms that the resource is closed.
Compose lifetimes deliberately
ExitStack supports a variable number of context managers and unwinds registered cleanup in reverse order. It is useful when resources are selected dynamically, but ordinary nested with blocks remain clearer for a small fixed set. Cleanup may itself fail, so error reporting should retain enough context to understand both the original problem and release failures. Test normal return, body failure, acquisition failure, and repeated use if reuse is promised. A generator-based context manager factory creates a fresh manager for each call; do not assume one manager instance is reusable. For async resources, use async context management rather than blocking a running event loop with synchronous acquisition or cleanup.
Common errors and fixes
A decorated function unexpectedly returns None
- Why it happens
- The wrapper forwards the call but omits return.
- How to fix it
- Return the wrapped result explicitly.
- Prevent it next time
- Assert return behavior for successful wrapped calls.
The wrapper hides a useful function name
- Why it happens
- Metadata was replaced by the wrapper’s own metadata.
- How to fix it
- Apply functools.wraps to the wrapper.
- Prevent it next time
- Check introspection where tooling depends on it.
An exception disappears inside with
- Why it happens
- The context manager catches it without re-raising.
- How to fix it
- Use finally for cleanup and re-raise unexpected exceptions.
- Prevent it next time
- Test exceptional exits as well as normal closure.
Check your understanding
1. What makes a retry decorator unsafe for an operation that sends an invoice?
Show worked solution
The first attempt might complete its side effect but fail before acknowledgment, so retrying can send a duplicate. Define idempotency and a bounded retry policy before wrapping the call. The decorator needs operation semantics, not only an exception list and a delay.
2. How do you verify cleanup when the with body raises?
Show worked solution
Create an observable test resource, enter its manager, deliberately raise a known exception, and catch it outside. Assert that the original exception propagated and that the resource reports closed. Those are two independent promises; merely seeing the exception does not prove cleanup.
Key takeaways
- A wrapper must preserve the intended call contract.
- wraps preserves metadata rather than all semantics.
- Context managers make resource lifetime explicit.