MidТеория7 min

Контекстные менеджеры

Протокол __enter__/__exit__, модуль contextlib, suppress, ExitStack и async-контекстные менеджеры

Контекстные менеджеры -- один из самых элегантных паттернов Python. Они гарантируют корректную очистку ресурсов (файлов, соединений, блокировок) даже при возникновении исключений. Инструкция with -- основной способ их использования.

Инструкция with

Базовый синтаксис with гарантирует вызов cleanup-кода:

# Without context manager — error-prone
file = open("data.txt")
try:
    content = file.read()
finally:
    file.close()

# With context manager — clean and safe
with open("data.txt") as file:
    content = file.read()
# File is automatically closed, even if an exception occurs

Несколько контекстных менеджеров

# Multiple context managers in one with statement
with (
    open("input.txt") as source,
    open("output.txt", "w") as target,
):
    for line in source:
        target.write(line.upper())

Протокол __enter__ / __exit__

Контекстный менеджер -- это объект, реализующий два метода:

class ManagedResource:
    """A resource that requires cleanup."""

    def __init__(self, name: str) -> None:
        self.name = name
        print(f"Creating resource: {name}")

    def __enter__(self):
        """Called when entering the with block."""
        print(f"Acquiring resource: {self.name}")
        return self  # This value is bound to the 'as' variable

    def __exit__(self, exc_type, exc_val, exc_tb):
        """Called when exiting the with block (always)."""
        print(f"Releasing resource: {self.name}")
        # Return True to suppress the exception
        # Return False (or None) to propagate it
        return False

with ManagedResource("database") as resource:
    print(f"Using: {resource.name}")
    # Output:
    # Creating resource: database
    # Acquiring resource: database
    # Using: database
    # Releasing resource: database

Обработка исключений в __exit__

class SafeTransaction:
    """Database transaction context manager."""

    def __init__(self, connection) -> None:
        self.connection = connection

    def __enter__(self):
        """Start a transaction."""
        self.connection.begin()
        return self.connection

    def __exit__(self, exc_type, exc_val, exc_tb):
        """Commit on success, rollback on error."""
        if exc_type is None:
            # No exception — commit
            self.connection.commit()
            print("Transaction committed")
        else:
            # Exception occurred — rollback
            self.connection.rollback()
            print(f"Transaction rolled back due to: {exc_val}")
        # Return False to propagate the exception
        return False

Подавление исключений

class SuppressErrors:
    """Context manager that suppresses specified exceptions."""

    def __init__(self, *exceptions: type[BaseException]) -> None:
        self.exceptions = exceptions

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        # Return True to suppress the exception
        if exc_type is not None and issubclass(exc_type, self.exceptions):
            print(f"Suppressed: {exc_type.__name__}: {exc_val}")
            return True
        return False

with SuppressErrors(FileNotFoundError, PermissionError):
    content = open("nonexistent.txt").read()
    # FileNotFoundError is suppressed
print("Execution continues normally")

Практические контекстные менеджеры

Timer -- измерение времени выполнения

import time

class Timer:
    """Measure execution time of a code block."""

    def __init__(self, label: str = "Block") -> None:
        self.label = label
        self.start: float = 0
        self.elapsed: float = 0

    def __enter__(self):
        self.start = time.perf_counter()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.elapsed = time.perf_counter() - self.start
        print(f"{self.label} took {self.elapsed:.4f}s")
        return False

with Timer("Data processing") as t:
    data = [i ** 2 for i in range(1_000_000)]

print(f"Elapsed: {t.elapsed:.4f}s")

TemporaryDirectory -- временная директория

import os
import shutil

class TemporaryWorkDir:
    """Create and cleanup a temporary working directory."""

    def __init__(self, base_path: str = "/tmp") -> None:
        self.path = os.path.join(base_path, f"work_{os.getpid()}")

    def __enter__(self) -> str:
        os.makedirs(self.path, exist_ok=True)
        return self.path

    def __exit__(self, exc_type, exc_val, exc_tb):
        shutil.rmtree(self.path, ignore_errors=True)
        return False

with TemporaryWorkDir() as workdir:
    # Work with temporary files
    filepath = os.path.join(workdir, "temp.txt")
    with open(filepath, "w") as f:
        f.write("temporary data")
# Directory is cleaned up automatically

Модуль contextlib

Стандартная библиотека предоставляет мощные инструменты для создания контекстных менеджеров.

@contextmanager -- из генератора

Декоратор @contextmanager превращает генератор в контекстный менеджер:

from contextlib import contextmanager

@contextmanager
def managed_connection(host: str, port: int):
    """Create a database connection with automatic cleanup."""
    print(f"Connecting to {host}:{port}")
    connection = {"host": host, "port": port, "active": True}

    try:
        yield connection  # Value bound to 'as' variable
    except Exception as e:
        print(f"Error during connection use: {e}")
        raise
    finally:
        # Cleanup — always runs
        connection["active"] = False
        print(f"Disconnected from {host}:{port}")

with managed_connection("localhost", 5432) as conn:
    print(f"Using connection: {conn}")

@contextmanager -- реальные примеры

from contextlib import contextmanager
import os
import sys
from io import StringIO

@contextmanager
def change_directory(path: str):
    """Temporarily change the working directory."""
    original = os.getcwd()
    try:
        os.chdir(path)
        yield path
    finally:
        os.chdir(original)

@contextmanager
def capture_output():
    """Capture stdout and stderr."""
    old_stdout, old_stderr = sys.stdout, sys.stderr
    sys.stdout = StringIO()
    sys.stderr = StringIO()
    try:
        yield sys.stdout, sys.stderr
    finally:
        sys.stdout, sys.stderr = old_stdout, old_stderr

@contextmanager
def environment_variable(key: str, value: str):
    """Temporarily set an environment variable."""
    old_value = os.environ.get(key)
    os.environ[key] = value
    try:
        yield
    finally:
        if old_value is None:
            del os.environ[key]
        else:
            os.environ[key] = old_value

# Usage
with environment_variable("DEBUG", "true"):
    print(os.environ["DEBUG"])  # "true"
# Original value is restored

contextlib.suppress

Элегантная замена try/except/pass:

from contextlib import suppress

# Instead of:
try:
    os.remove("temp.txt")
except FileNotFoundError:
    pass

# Use:
with suppress(FileNotFoundError):
    os.remove("temp.txt")

# Multiple exception types
with suppress(FileNotFoundError, PermissionError):
    os.remove("protected.txt")

contextlib.ExitStack

ExitStack управляет динамическим набором контекстных менеджеров:

from contextlib import ExitStack

def process_multiple_files(paths: list[str]) -> list[str]:
    """Open and process multiple files safely."""
    with ExitStack() as stack:
        # Open all files — all will be closed on exit
        files = [stack.enter_context(open(path)) for path in paths]

        # Process all files
        results = []
        for f in files:
            results.append(f.read())
        return results

# Dynamic number of context managers
def create_connections(hosts: list[str]) -> list[dict]:
    """Create multiple connections with guaranteed cleanup."""
    with ExitStack() as stack:
        connections = []
        for host in hosts:
            conn = stack.enter_context(managed_connection(host, 5432))
            connections.append(conn)

        # Use all connections
        return [{"host": c["host"], "active": c["active"]} for c in connections]

ExitStack с callback

from contextlib import ExitStack

def setup_environment():
    """Set up environment with cleanup callbacks."""
    with ExitStack() as stack:
        # Register cleanup callbacks
        stack.callback(print, "Cleanup step 3: done")
        stack.callback(print, "Cleanup step 2: done")
        stack.callback(print, "Cleanup step 1: done")

        print("Environment is set up")
        # On exit, callbacks run in LIFO order:
        # Cleanup step 1: done
        # Cleanup step 2: done
        # Cleanup step 3: done

Async-контекстные менеджеры

Для асинхронного кода используются async with и методы __aenter__/__aexit__:

import asyncio
from contextlib import asynccontextmanager

class AsyncDatabasePool:
    """Async database connection pool."""

    async def __aenter__(self):
        """Asynchronously acquire a connection."""
        print("Acquiring async connection...")
        await asyncio.sleep(0.1)  # Simulate async connection
        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        """Release the connection."""
        print("Releasing async connection...")
        await asyncio.sleep(0.05)  # Simulate async cleanup
        return False

    async def query(self, sql: str) -> list:
        """Execute a query."""
        await asyncio.sleep(0.01)
        return [{"result": sql}]

async def main():
    async with AsyncDatabasePool() as pool:
        result = await pool.query("SELECT * FROM users")
        print(result)

@asynccontextmanager

from contextlib import asynccontextmanager
import asyncio

@asynccontextmanager
async def async_timer(label: str):
    """Async version of the timer context manager."""
    import time
    start = time.perf_counter()
    try:
        yield
    finally:
        elapsed = time.perf_counter() - start
        print(f"{label}: {elapsed:.4f}s")

async def main():
    async with async_timer("API call"):
        await asyncio.sleep(0.5)
    # Output: API call: 0.5004s

Паттерн: контекстный менеджер как декоратор

contextlib.ContextDecorator позволяет использовать контекстный менеджер и как with, и как декоратор:

from contextlib import ContextDecorator
import time

class log_execution(ContextDecorator):
    """Log function execution time and exceptions."""

    def __init__(self, label: str = "block") -> None:
        self.label = label

    def __enter__(self):
        self.start = time.perf_counter()
        print(f"[{self.label}] Starting...")
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        elapsed = time.perf_counter() - self.start
        if exc_type:
            print(f"[{self.label}] Failed after {elapsed:.4f}s: {exc_val}")
        else:
            print(f"[{self.label}] Completed in {elapsed:.4f}s")
        return False

# Use as context manager
with log_execution("data processing"):
    data = [i ** 2 for i in range(100_000)]

# Use as decorator
@log_execution("compute")
def heavy_computation():
    return sum(i ** 2 for i in range(1_000_000))

heavy_computation()

Проверь себя

Для чего используется ExitStack?

Что произойдет, если __exit__ вернет True?

Как создать контекстный менеджер из генератора?

Что делает contextlib.suppress?

Какие два метода должен реализовать контекстный менеджер?