Batu Lab NotesPractical developer guides

Explain why ContextDecorator can hide shared state

By Batu · English technical notes

Also published in our primary archive.

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