2025, Dec 04 09:02

Зачем _RedirectStream хранит список целей в contextlib

Разбираем реентерабельные перенаправители в Python contextlib: почему _RedirectStream хранит список и как избежать сбоев при вложенных redirect_stdout.

Реентерабельные (реентрантные) перенаправители в contextlib часто кажутся излишней сложностью — пока не столкнёшься с вложенным сценарием. Один тонкий, но важный момент — внутренний _RedirectStream, на котором основаны redirect_stdout, redirect_stderr и им подобные. Небольшое конструкторское решение — хранить прежние цели в списке — и обеспечивает безопасное повторное использование одного и того же экземпляра внутри вложенных блоков with.

Суть проблемы одним взглядом

Проблема возникает, когда в один и тот же контекст перенаправления входят повторно до его выхода. Если реализация хранит лишь одну «предыдущую» цель, внутренний вход перезаписывает эту ссылку, и внешний выход восстанавливает неправильный поток. Это сразу проявляется, как только вы пытаетесь переиспользовать один экземпляр перенаправителя во вложенном коде.

Минимальный пример, который ломается

Класс ниже хранит только одну «старую» цель. Двойной вход с одним и тем же объектом нарушает восстановление, поэтому завершающий print уходит в файл, а не в терминал.

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')  # С SingleSlotRedirect это попадает в file1, а не в терминал

Почему это ломается

При первом входе перенаправитель сохраняет текущий sys.stdout как прежнюю цель и переключает вывод в файл. При втором входе тем же экземпляром он перезаписывает сохранённое значение уже самим файловым дескриптором. Когда внутренний with завершается, stdout восстанавливается к последнему сохранённому — к файлу; когда выходит внешний with, восстановление снова идёт к тому же файловому объекту. Терминал так и не возвращается, поэтому строка "after" продолжает перенаправляться.

Это особенно заметно в переплетённых сценариях, где вы вкладываете разные перенаправления и переиспользуете внешний перенаправитель внутри внутреннего блока. Внешний обязан надёжно восстановить именно свою прежнюю цель, а не то, что в последний раз сохранил какой-то вложенный вход.

Решение: хранить предыдущие цели в стеке

Надёжный подход — при входе «класть» текущую цель в стек, а при выходе — «снимать» её. Именно поэтому _RedirectStream хранит список: он работает как стек и делает контекстный менеджер реентерабельным.

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())

С такой схемой каждый вход независимо фиксирует текущую цель. Раскрутка стека восстанавливает их в правильном порядке, даже если один и тот же экземпляр входит в контекст несколько раз или используется внутри других перенаправлений.

Вложенные и переплетённые перенаправления

Вот ситуация, где реентерабельность особенно кстати: один перенаправитель переиспользуется внутри области действия другого. После выхода из внутреннего блока внешний всё равно должен вернуть свою прежнюю цель.

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')

Дисциплина стека гарантирует, что каждый выход восстановит ровно ту цель, которая соответствовала его входу, а не значение, перезаписанное другим вложенным использованием.

Почему это важно

Перенаправление stdin, stdout или stderr часто применяют, чтобы перехватывать вывод, управлять тестами или временно подавлять «шум». В таких ситуациях легко повторно войти в один и тот же экземпляр перенаправителя во вложенных областях. Если контекстный менеджер не реентерабелен, незаметная «утечка» перенаправления во внешние области приведёт к тому, что вывод окажется в файлах, когда должен вернуться в терминал, и наоборот. Стек на базе списка устраняет целый класс подобных ошибок.

Выводы

Если контекстный менеджер меняет состояние всего процесса, он должен уметь отменять изменения строго в порядке LIFO, сколько бы раз в него ни входили одним и тем же экземпляром. Хранение прежних целей в стеке — простой и действенный способ гарантировать корректное восстановление. В этом и состоит практический смысл того, что _RedirectStream хранит _old_targets как список, а redirect_stdout безопасно справляется с вложенным переиспользованием.