2025, Nov 23 09:00

Stack-based redirect_stdout and _RedirectStream: making Python context managers safely reentrant

Why Python contextlib redirectors must be reentrant: how _RedirectStream and redirect_stdout use a stack to restore stdout correctly in nested with blocks.

Reentrant redirectors in contextlib often look like overengineering until a nested use case bites. One subtle place where it matters is the internal _RedirectStream used by redirect_stdout, redirect_stderr and friends. A tiny design choice — keeping prior targets in a list — is what makes the same instance safe to reuse inside nested with blocks.

Problem in one look

The issue appears when the same redirecting context is entered more than once before it exits. If the implementation stores only a single previous target, the inner entry overwrites that reference, and the outer exit restores to the wrong stream. The effect is visible as soon as you try to reuse one redirector instance in nested code.

Minimal failing example

The following class stores only one "old" target. Entering it twice with the same object breaks restoration, so the trailing print ends up in a file instead of the terminal.

from contextlib import AbstractContextManager
import sys

class SingleSlotRedirect(AbstractContextManager):

    channel = "stdout"

    def __init__(self, sink):
        self._sink = sink
        self._prev = None

    def __enter__(self):
        self._prev = getattr(sys, self.channel)
        setattr(sys, self.channel, self._sink)
        return self._sink

    def __exit__(self, exc_t, exc_v, exc_tb):
        setattr(sys, self.channel, self._prev)


f1 = open('file1.txt', 'wt')

redir = SingleSlotRedirect(f1)

print('before')

with redir:
    print("write redirect1 - 1")

    with redir:
        print("write redirect1 - 2")

print('after')  # With SingleSlotRedirect, this lands in file1 instead of the terminal

Why it breaks

On the first entry, the redirector stores the current sys.stdout as the previous target and switches to the file. On the second entry with the same instance, it overwrites that stored value with the file handle itself. When the inner with exits, it restores stdout to the value it saved most recently — the file — and when the outer with exits, it restores again to the same file handle. The terminal never returns, so the "after" line is still redirected.

This becomes even more noticeable in intertwined scenarios where you nest different redirections and reuse the outer redirector inside the inner block. The outer one must reliably restore to its own prior target, not whatever the last nested entry happened to save.

The fix: treat prior targets as a stack

The robust approach is to push the current target on entry and pop it on exit. That’s exactly why _RedirectStream keeps a list: it acts like a stack and makes the context manager reentrant.

from contextlib import AbstractContextManager
import sys

class StackBasedRedirect(AbstractContextManager):

    channel = "stdout"

    def __init__(self, sink):
        self._sink = sink
        self._history = []

    def __enter__(self):
        self._history.append(getattr(sys, self.channel))
        setattr(sys, self.channel, self._sink)
        return self._sink

    def __exit__(self, exc_t, exc_v, exc_tb):
        setattr(sys, self.channel, self._history.pop())

With this design, each entry records the current target independently. Unwinding restores in the correct order, even if a single instance is re-entered multiple times or reused within other redirections.

Nested and intertwined redirections

Here is a scenario where reentrancy shows its value: one redirector is reused inside the scope of another. The outer one must still restore its own previous target after the inner block exits.

from contextlib import AbstractContextManager
import sys

class StackBasedRedirect(AbstractContextManager):

    channel = "stdout"

    def __init__(self, sink):
        self._sink = sink
        self._history = []

    def __enter__(self):
        self._history.append(getattr(sys, self.channel))
        setattr(sys, self.channel, self._sink)
        return self._sink

    def __exit__(self, exc_t, exc_v, exc_tb):
        setattr(sys, self.channel, self._history.pop())


f1 = open('file1.txt', 'wt')
f2 = open('file2.txt', 'wt')

r1 = StackBasedRedirect(f1)
r2 = StackBasedRedirect(f2)

print('before')

with r1:
    print("write redirect1 - 1")

    with r2:
        print("write redirect2 - 1")

        with r1:
            print("write redirect1 - 2")

        print("write redirect2 - 2")

print('after')

The stack discipline ensures that each exit restores to the correct target for its corresponding entry, not a value overwritten by another nested use.

Why this matters

Redirecting stdin, stdout or stderr is frequently used for capturing output, driving tests, or temporarily silencing noise. In these situations, it is easy to re-enter the same redirector instance across nested scopes. If the context manager is not reentrant, subtle leakage of redirection into outer scopes causes output to end up in files when it should return to the terminal, or vice versa. The list-backed stack eliminates this entire class of bugs.

Takeaways

When a context manager mutates process-wide state, it must be able to undo changes precisely in LIFO order, regardless of how many times the same instance is entered. Keeping prior targets in a stack is a simple, effective way to guarantee correct restoration. That’s the practical reason _RedirectStream stores _old_targets as a list and why redirect_stdout handles nested reuse safely.