ExitStack в Python: как закрыть несколько ресурсов при ошибке

Программа объединяет несколько текстовых файлов. Их список приходит из конфигурации, поэтому заранее написать три вложенных with нельзя. Вторая попытка открытия завершается ошибкой. Первый файл уже открыт, а до обычного цикла закрытия программа не дошла.

Почему списка файлов недостаточно

Ресурс появляется в момент успешного open. С этого момента у программы есть обязанность закрыть его при любом выходе из участка работы. Если сначала собрать список открытых файлов, а затем поставить try/finally, ошибка во время самого построения списка случится раньше защиты. Именно частичная подготовка делает этот случай сложнее обычного чтения одного файла.

ExitStack регистрирует контекстные менеджеры по мере появления. Вызов enter_context сначала входит в очередной контекст, затем сохраняет его выход. Если вход не удался, выход для этого ресурса регистрировать не требуется. Выходы предыдущих успешных контекстов уже находятся в стеке и будут выполнены при раскручивании внешнего with.

Разделить владение и чтение

Функция ниже владеет всеми входными файлами. Она возвращает готовую строку, поэтому вызывающему коду не приходится угадывать срок жизни открытых дескрипторов. Код чтения остаётся внутри with ExitStack: к этому моменту файлы ещё доступны. Возвращать из этого блока ленивый генератор строк опасно — фактическое чтение начнётся уже после закрытия файлов.

Для проверки возьмём настоящие временные файлы. Обёртка вокруг open записывает открытые объекты и позволяет проверить их свойство closed после ошибки. Она не подменяет файловую систему: отсутствие missing.txt действительно вызывает FileNotFoundError. В production-функции параметр opener можно убрать; здесь он делает управление ресурсами наблюдаемым без глобальной подмены встроенного open.

Успешная и прерванная подготовка

Запустите пример обычным Python. Первый вызов объединит два файла. Второй прочитать их не сможет: отсутствует следующий вход. В обоих случаях все успешно открытые объекты должны оказаться закрыты. Проверяем именно это свойство, а не внутреннее устройство стека.

from contextlib import ExitStack
from pathlib import Path
from tempfile import TemporaryDirectory

def combine(paths, opener=open):
    with ExitStack() as stack:
        files = [stack.enter_context(opener(p, encoding="utf-8"))
                 for p in paths]
        return "".join(f.read() for f in files)

opened = []
def tracked_open(path, **kwargs):
    f = open(path, **kwargs)
    opened.append(f)
    return f

with TemporaryDirectory() as directory:
    root = Path(directory)
    a, b = root / "a.txt", root / "b.txt"
    a.write_text("alpha\n", encoding="utf-8")
    b.write_text("beta\n", encoding="utf-8")
    assert combine([a, b], tracked_open) == "alpha\nbeta\n"
    assert all(f.closed for f in opened)
    opened.clear()
    try:
        combine([a, root / "missing.txt"], tracked_open)
    except FileNotFoundError:
        pass
    else:
        raise AssertionError("missing file must fail")
    assert len(opened) == 1 and opened[0].closed
print("resources closed in both paths")

Порядок освобождения тоже имеет смысл

ExitStack освобождает контексты в обратном порядке. Это соответствует вложенным with: последний полученный ресурс часто зависит от предыдущего и должен завершиться раньше. Если вместе с файлами используются блокировка и временный каталог, их порядок регистрации задаёт порядок завершения. Сначала регистрируйте то, что должно жить дольше остальных.

Защита касается выхода из участка программы, включая исключение. Она не превращает запись в транзакцию: если вы успели изменить первый файл, закрытие не вернёт старое содержимое. Для изменения нескольких выходов нужен отдельный план публикации результата. В задаче чтения достаточно обеспечить срок жизни дескрипторов и вернуть вычисленное значение.

Практика перед усложнением конструкции

В открытом уроке по первым тестам pytest можно отработать успешные и ошибочные сценарии. Возьмите их как основу для проверки закрытия ресурсов: сначала нормальный вход, затем ошибка на втором файле. Откройте программу продвинутого Python: блок работы с pathlib и тестами файловых операций помогает перейти от примера к своему пакету.

Продолжить на практике

  • Python для продвинутых: ООП, типизация и тестирование ↗

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