Explain why ContextDecorator can hide shared state
Explain why ContextDecorator can hide shared state
Direct answer: a ContextDecorator instance used directly as @decorator is reused by decorated calls. If it stores a mutable list on self, that list can silently accumulate state across calls. Use a decorator factory that creates a fresh context-manager instance for every invocation when per-call state is required.
The first experiment creates one CallRecorder and decorates shared_first and shared_second with it. Each __enter__ appends to self.calls; consequently the reported counts are 1 and 2. Nothing about the two functions asks them to share a list—the shared decorator object owns it.
The repair is record_each_call(), a factory returning a decorator. Its wrapper constructs CallRecorder() inside the wrapper, uses it for one with block, and returns the result plus the count observed for that call. The two corrected functions therefore each report 1. functools.wraps() preserves basic function metadata but does not create the isolation; instance construction does.
ContextDecorator lets a context manager act as a decorator and requires the underlying context manager to support multiple uses. ContextDecorator is available in all supported Python 3 versions. The assertions demonstrate ownership for this fixture, not thread safety or the suitability of every context manager for reuse.
AI-assistance disclosure: Batu Lab Notes used AI assistance to draft this synthetic example and explanation.
Example
from contextlib import ContextDecorator
from functools import wraps
class CallRecorder(ContextDecorator):
def __init__(self):
self.calls = []
def __enter__(self):
self.calls.append("entered")
return self
def __exit__(self, exc_type, exc, traceback):
return False
shared = CallRecorder()
@shared
def shared_first():
return len(shared.calls)
@shared
def shared_second():
return len(shared.calls)
def record_each_call():
def decorate(function):
@wraps(function)
def wrapped():
with CallRecorder() as recorder:
result = function()
return result, len(recorder.calls)
return wrapped
return decorate
@record_each_call()
def fresh_first():
return "first"
@record_each_call()
def fresh_second():
return "second"
shared_counts = (shared_first(), shared_second())
fresh_counts = (fresh_first()[1], fresh_second()[1])
assert shared_counts == (1, 2)
assert fresh_counts == (1, 1)
print(f"shared call counts: {shared_counts[0]},{shared_counts[1]}")
print(f"fresh call counts: {fresh_counts[0]},{fresh_counts[1]}")
Expected output:
shared call counts: 1,2
fresh call counts: 1,1